Dev.to WebDev 🛠 Dev 👁 0 📖 8 min read

The New HTTP QUERY Method, and Using It in Django

This is my first post on here, so bear with me. It's about the new HTTP QUERY method, and a small package I've put together so anyone can use it with Django REST framework now, rather than waiting for more widespread ado

This is my first post on here, so bear with me. It's about the new HTTP QUERY method, and a small package I've put together so anyone can use it with Django REST framework now, rather than waiting for more widespread adoption. But mostly it's about the problem that made me care about it in the first place.

The problem: GET URLs that kept growing

I look after a Django project for a client where most of the app is big filterable, sortable tables. They're server-side DataTables, backed by django-rest-framework-datatables, with django-filter doing a lot of the filtering on the backend. It's a setup I really like - it's simple to set up and modify over time, and all the searching, sorting and paging happens in the database.

The issue is how DataTables asks. In server-side mode, by default, it sends everything as a GET query string. Not just the search box and the page, but six parameters for every single column (columns[3][data], columns[3][searchable], columns[3][search][value] and so on), plus the ordering, plus whatever extra filters you bolt on.

Our main table has 21 columns, and on top of that the filter panel adds around 40 more parameters - booleans, ranges, multi-selects, tags, and so on. When I rebuilt a request roughly from today's table, a relatively simple URL came out at about 4.8KB. Pick a handful of options in one of the multi-selects and it's well past 6KB.

Gunicorn's default limit on the request line is 4094 bytes. So, unsurprisingly, years ago, it fell over, and left me scrambling to try and work out why, and how to fix it. My fix back in November 2022 was a commit called, in full, "Attempt to fix gunicorn error.", which added --limit-request-line 0 to the gunicorn start script. Zero means no limit at all.

That worked, but it never sat right with me. I have always felt there must be a better approach.

Why not just use POST?

The obvious answer is to switch the tables to POST and put everything in the body.

But it's a read pretending to be a write. POST says "this changes something", so it isn't cacheable, it isn't safe to retry, it needs a CSRF token for what is just a search, and any permission class that checks for safe methods treats it as a write. With DRF in particular, IsAuthenticatedOrReadOnly and friends would see a list request as an attempt to create something. You end up working around the framework instead of with it.

So it was either a URL nobody should have to look at, or a request that lies about what it does. Selfishly, I just wanted my tables to work properly, but I think a lot of people are in the same spot.

What QUERY is

RFC 10008 defines a new HTTP method, QUERY. It's the first new method since PATCH. The short version is that it's safe and idempotent like GET, but it carries a request body like POST.

It fills the gap between GET and POST exactly, which makes it a perfect fit for dynamic filtering - search screens, filter panels and big tables like mine, where users can combine any number of filters. Filters go in the body, so the size limit is one you control rather than the tightest thing on the path, and the request is still honestly a read, so retries, permissions and (in principle) caching all behave the way they should.

Where things are at

Django has merged QUERY support for 6.2 (django/django#21855, by Michiel Beijen). "query" is now in View.http_method_names, so a class-based view just defines a query() method. It's treated as safe throughout, so it's exempt from CSRF, cache_page and the cache middleware don't cache it, and the test client gets RequestFactory.query().

That's the foundation, but I don't write plain Django views for my APIs. I use packages like DRF, and DRF (along with the packages around it) doesn't know about QUERY yet. When I talked through how I could help make this new method easily adoptable with my mentor, Tim Schilling (fantastic, by the way, if anyone is looking for coding-based mentoring, whether that's technical support or career support), his suggestion was to give people something they can use now, as well as contributing to the wider community directly. So here we are.

drf-query-method

Until the rest of the ecosystem catches up, I've put together drf-query-method, a small package that adds QUERY support to DRF today.

pip install drf-query-method

Call patch() once at startup, for example in an AppConfig.ready(), and give your viewset a query() handler:

import drf_query_method

drf_query_method.patch()


class NoteViewSet(ModelViewSet):
    queryset = Note.objects.all()
    serializer_class = NoteSerializer
    filter_backends = [SearchFilter, OrderingFilter]
    search_fields = ["title"]
    ordering_fields = ["created", "title"]

    def query(self, request, *args, **kwargs):
        return self.list(request, *args, **kwargs)

Now QUERY /notes/ goes to query(), and the filters read their parameters from the body:

QUERY /notes/ HTTP/1.1
Content-Type: application/json

{"search": "django", "ordering": "-created,title"}

If you'd rather not patch DRF, there are opt-in classes with the same names as DRF's (drf_query_method.routers, .viewsets, .filters, .permissions, .test), so it's an import change rather than a code change.

It also works on older Django. Django 6.2 adds QUERY itself; on 4.2 to 6.1 the package adds it for you (add_query_method(), which patch() calls).

A quick word on CSRF, because it's one of the nice things about QUERY: you don't need a token. It's a safe method, so Django 6.2 skips the CSRF check for it, just as it does for GET. Older versions of Django's CSRF middleware only know about GET, HEAD, OPTIONS and TRACE, so on 4.2 to 6.1 the package teaches it that QUERY is safe too. Either way, you don't send a CSRF token, even with session authentication. It's still protected against cross-site requests by CORS: browsers can only send QUERY from JavaScript, and a cross-origin QUERY needs your server's permission first. The rule is the same as for GET - a query() handler should never change anything.

Closing the loop on DataTables

This was the bit I actually wanted. If django-filter is installed, patch() makes DjangoFilterBackend read the body too, and as of 0.2.0 it does the same for django-rest-framework-datatables - its filter backends, pagination and renderer.

So the table setup becomes:

new DataTable("#notes", {
  serverSide: true,
  ajax: {
    url: "/api/notes/?format=datatables",
    type: "QUERY",
  },
  columns: [{data: "title"}, {data: "created"}],
});

jQuery sends data as a form-encoded body for any method other than GET and HEAD, which is the shape django-rest-framework-datatables already expects. format=datatables stays in the URL, and everything else moves into the body. And there's no CSRF token to send, because QUERY is a safe method.

htmx supports it too

For htmx lovers like myself, who are probably wondering whether this works with htmx - it does, and htmx got there first. htmx 4 (released on 28 August 2026) adds an hx-query attribute. It sends a QUERY request with the form values in the body, the same way hx-post does, rather than squeezing them into the URL:

<form hx-query="/notes/search/" hx-target="#results">
  <input name="search">
  <select name="ordering">...</select>
</form>

htmx form-encodes the values, and filter_params merges a form-encoded body over the URL parameters, so SearchFilter, OrderingFilter and (with patch()) django-filter all pick them up with nothing extra. And as with DataTables, there's no CSRF header to add.

The one thing to know is that htmx 4 sits under the next tag on npm, with 2.x still on latest, so you have to ask for version 4 explicitly. htmx 2 doesn't have hx-query.

It's on PyPI (0.3.0 at the time of writing), and the docs are here.

The plan: make this package redundant

I'd honestly rather this package didn't need to exist for long. The aim is for the main packages to support QUERY themselves, at which point you can drop drf-query-method from your dependencies and rely on them instead. To help with that, it deliberately uses the same names as the upstream work - query() handlers and request.filter_params - so removing it should mean deleting a line or two rather than rewriting your views.

DRF. Back in August I opened a discussion on DRF about supporting QUERY. DRF describes itself as essentially feature-complete, and the reply at the time was, reasonably, to land it in Django first. As that's now happened, I've opened a PR to update DRF accordingly encode/django-rest-framework#10091. It adds QUERY to SAFE_METHODS and the permission maps, routes it to a viewset's query() on the list route, adds request.filter_params for the filters to read, and adds .query() to the test client.

django-filter. If and when the DRF change is merged, I'd like to contribute the matching change to django-filter, so DjangoFilterBackend reads QUERY bodies through the same filter_params idea. I've already worked out what needs to change.

drf-spectacular. Schema generation currently crashes with KeyError: 'query' on any view with a query() handler, so I've opened a one-line fix: tfranzel/drf-spectacular#1553.

Once those land, the main ecosystem supports QUERY and drf-query-method has done its job. If it doesn't work out - some big packages are understandably conservative about adopting new things too quickly - the package stays as the fix, and I'll keep it maintained.

Things to watch out for

It's a new method, so not everything knows about it yet.

  • CORS. QUERY isn't a CORS-safelisted method, so cross-origin requests get a preflight and the server has to allow it. With django-cors-headers, add it to CORS_ALLOW_METHODS.
  • Proxies, CDNs and firewalls. Some reject methods they don't recognise. Check it actually reaches Django through everything in front of it.
  • Caching. Responses are cacheable in principle, but caches generally don't key on request bodies, and Django's cache_page won't cache QUERY. Don't expect it to be cached.
  • The browsable API can't send QUERY, so you'll be testing with curl, fetch() or the test client.

Feedback welcome

This is my first real go at contributing to DRF, so I'd really appreciate people kicking the tyres. If you've got a filter-heavy API or a big DataTable, try it and tell me what breaks:

And if you think QUERY belongs in DRF itself (or don't), a comment on the pull request would help the maintainers see whether people actually want it. Thanks for reading!

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.