How a Single Test Revealed a Bug in Django 6.0

The story of how a failed upgrade attempt by Renovate led to a Django contribution.

One of the client projects I work on needs constant upkeep. It depends on fast-moving libraries, so staying up-to-date is a must. Falling behind means riskier updates and time wasted in dependency hell.

For a long time, we used Dependabot to open upgrade PRs and merged them manually after CI passed. That worked fine… until we switched the project to uv. At the time, Dependabot didn’t support uv lockfiles, so I went looking for an alternative and found Renovate.

That turned out to be a huge win. Along with uv.lock support, Renovate is highly configurable and works across more platforms and languages.

But waiting for CI and manually rebasing and merging PRs remained tedious and time consuming. So we pushed it further and enabled automerge so Renovate PRs merge automatically once CI passes. Most of the time, this churns quietly in the background. Occasionally a build fails and needs human attention.

A Failing Test

When Django 6.0 was released in December 2025, Renovate opened a pull request and CI failed. I investigated.

Out of more than 2,200 tests (unit, integration, and browser), only one failed in this major version upgrade. That’s remarkable, and a testament to Django’s stability and maturity. Well done!

The failing test verified that the rendered href attribute of an HTML <a> tag contains a querystring with multi-value keys, like this:

/?content_type=1&content_type=2&object_id=1&object_id=2

It’s an older test that was written back when the project was on Django 4.2. Even after replacing the original implementation with the new querystring template tag introduced in Django 5.1, the test never changed. More on this later.

In the Django 6.0 PR, the same link rendered like this:

/?content_type=2&object_id=2

Only the last value for each key survived, yet no code had changed. The branch only bumped Django from 5.2.9 to 6.0 in pyproject.toml and uv.lock.

I checked the Django 6.0 release notes and noticed the querystring tag appeared in the changelog. Either the syntax had changed, or this was a bug in Django. I suspected the latter.

Contributing to Django

To confirm this hunch, I tried to reproduce it inside Django itself. I looked at the existing querystring tests and found no scenario covering multi-value query parameters.

So, I added a test case. It failed on 6.0, but passed when applied to the stable/5.2.x branch.

Satisfied, I committed the failing test, opened a ticket in Django’s bug tracker, and took a shot at fixing it.

The regression came from a change in how querystring built its parameters. In Django 6.0, the template tag gained support for multiple positional mapping arguments, so the implementation was updated to merge request.GET with additional mappings passed to the tag.

That refactor introduced a for loop over each mapping’s .items(). This works for normal dictionaries, but it breaks for QueryDict instances since QueryDict.items() only keeps a single value per key. That’s why only the last content_type and object_id survived in the rendered URL, and the test failed.

To preserve the full list of values, QueryDict instances needed to be handled differently. Instead of iterating with .items(), the code needed to use .lists(), which is like items(), except it includes all values, as a list, for each member of the dictionary.

It looked like an easy fix. But as you can see for yourself in the PR discussion, there was more to it… but the PR ultimately landed.

Why the failing test was a good one

Back to that failing test in the client project.

Originally, the URL was generated by a custom model method named get_filtered_url which was called from a Django template.

The test set up conditions that would render a URL with multi-value querystring parameters, then parsed the HTML response with BeautifulSoup.

from bs4 import BeautifulSoup


def test_filtered_url_with_multiple_objects(client):
    ...
    response = client.get(url)
    assert response.status_code == 200
  
    soup = BeautifulSoup(response.content, "html.parser")
    link = soup.find(id="my_link")
    assert link

    expected = "/?content_type=1&content_type=2&object_id=1&object_id=2"
    result = link.attrs["href"]
    assert result == expected

When the project was upgraded to Django 5.1, the template was changed to use {% querystring %}, instead of the get_filtered_url method.

The existing test continued to pass, but to validate that the {% querystring %} code path was actually executed, I commented out the tag and reran the test to make sure it went red. Once it failed, I knew the test was truly covering this code block.

An alternative (and common) approach to testing the rendered output would have been to write a unit test that called the get_filtered_url method directly.

It might have achieved 100% coverage, but it would have verified how the code was written, not the outcome. After the refactor, the method could have quietly turned into zombie code; no longer called from the template and only from tests.

Worse, deleting or replacing that method would break tests even though the user-facing behaviour stayed the same. This testing approach would have missed the Django 6 regression entirely and the bug would have been deployed to production.

When tests suffer from implementation bias, you get the worst of both worlds: refactoring becomes discouraged (and therefore avoided) while regressions may slip by, unnoticed.

For more on this, check out Eric Matthes’ write-up, Empathetic Testing, and my talk at DjangoCon US 2023, Empathetic Testing: Developing With Compassion and Humility.

Conclusion

My invitation to you, and the takeaways from this story are:

1) Write tests that outlive the code behind them

Consider writing your tests before the code. This helps ensure your tests focus on expected results, not implementation choices. That way, you can change how it’s built later, and you’ll catch regressions when dependencies change underneath you.

2) Automate dependency upgrades

Tools like Dependabot and Renovate turn upgrades into a steady stream of small, reviewable changes. When CI is reliable, most updates merge quietly in the background, and you only get pulled in when something actually breaks. You might even end up contributing to open source.

3) See something, say something. Better yet, do something!

If you think you’ve found a bug, try to reproduce it with a failing test. File an issue, and take a shot at the fix.

Special thanks to Jacob Tyler Walls (@jacobtylerwalls) and Natalia Bidart (@nessita) for their help with the fix and including it in the Django 6.0.1 release.

Meanwhile, Renovate can continue doing its thing. 🚀

Marc Gibbons

About the author

Marc Gibbons

Marc caught the programming bug as a child when the internet was still text-based and accessed by a 9600 baud modem. His career path is unique; he initially studied music and played the oboe professionally with …

View Marc's profile