Django: Fix version 5.0’s URLField.assume_scheme warnings

HTTP versus HTTPS, which will win this daring game of simplicity versus security?

Since Django’s inception, the web has gradually moved from HTTP to HTTPS, a welcome move for security. But the history has meant older parts of Django have had a lingering HTTP bias. Many of these have been migrated to default to HTTPS instead in previous versions. Django 5.0 starts the migration of another, tiny HTTP bias in forms.URLField.

The old behaviour: when URLField is provided a URL without a scheme, it assumes it to be “http”:

In [1]: from django import forms

In [2]: forms.URLField().to_python('example.com')
Out[2]: 'http://example.com'

Django 5.0 has started a deprecation process to change this default to “https” (Ticket #34380). This version shows a PendingDeprecationWarning when instantiating a URLField:

In [1]: from django import forms

In [2]: forms.URLField().to_python('example.com')
<ipython-...>:1: RemovedInDjango60Warning: The default scheme will be changed from 'http' to 'https' in Django 6.0. Pass the forms.URLField.assume_scheme argument to silence this warning.
  forms.URLField().to_python('example.com')
Out[2]: 'http://example.com'

Here’s that warning message in a more readable format:

RemovedInDjango60Warning: The default scheme will be changed from 'http' to 'https' in Django 6.0. Pass the forms.URLField.assume_scheme argument to silence this warning.

Django 5.1 will turn that into a DeprecationWarning and Django 6.0 will change the default and remove the warning.

Here’s the related release note:

The default scheme for forms.URLField will change from "http" to "https" in Django 6.0. Set FORMS_URLFIELD_ASSUME_HTTPS transitional setting to True to opt into assuming "https" during the Django 5.x release cycle.

Update (2023-12-11): Added the following note about <input type=url> after John-Scott Atlakson pointed out the behaviour on the forum.

Receiving a schemeless URL in a URLField should actually be quite rare. By default, a URLField renders as <input type=url>:

In [1]: from django import forms

In [2]: class CheckForm(forms.Form):
   ...:     url = forms.URLField(label="URL to check")
   ...:

In [3]: print(CheckForm()["url"].as_field_group())
<label for="id_url">URL to check:</label>



<input type="url" name="url" required id="id_url">

Browsers’ client-side validation for <input type=url> requires a scheme for URLs. To hit the “assume scheme” code path, the field will need to use a different widget or template, or the client-side validation would need to be disabled.

But still, until Django 6.0, there’s this new warning to account for. You can control the warning in one of two ways:

  1. Adopt the future behaviour with the FORMS_URLFIELD_ASSUME_HTTPS transitional setting.
  2. Migrate individual forms.URLField instances by adding the assume_scheme argument.

We’ll cover both of those options below.

I think the first option is suitable for most projects, where there are likely few concerns around users entering schemeless HTTP URLs. Most links are copy-pasted from the browser, in which case they come with a scheme. For the rest, Google Chrome statistics show >93% of page loads use HTTPS. Also, if you go with the second option, you’ll need to deal with warnings for every forms.URLField in your project which can be a lot of work.

1. Adopt the future behaviour with the transitional setting

Enable FORMS_URLFIELD_ASSUME_HTTPS in your settings file to make all forms.URLField instances assume “https”:

FORMS_URLFIELD_ASSUME_HTTPS = True

The setting is transitional, meaning it will be removed in Django 6.0. Because of this, enabling it triggers a different warning when Django starts:

RemovedInDjango60Warning: The FORMS_URLFIELD_ASSUME_HTTPS transitional setting is deprecated.

Silence that warning with one of Python’s warning control mechanisms, such as warnings.filterwarnings(). It’s pretty easy to do so right next to the setting:

from warnings import filterwarnings

filterwarnings(
    "ignore", "The FORMS_URLFIELD_ASSUME_HTTPS transitional setting is deprecated."
)
FORMS_URLFIELD_ASSUME_HTTPS = True

That’s all you need for this migration option!

2. Migrate individual forms.URLField instances

For this option, you need to add the assume_scheme argument to each URLField instance. You can set it to "https" to adopt the future default behaviour or "http" to retain the old behaviour—any explicit definition silences the warning.

But passing the argument can be hard depending on how the form class is defined or created. Here are some different ways form classes may exist in your project.

Plain Forms with a URLField wrapper

For forms that you directly define, add the assume_scheme argument to any URLField instances:

from django import forms


class CheckForm(forms.Form):
    url = forms.URLField(label="URL to check", assume_scheme="https")

If you have many URLFields, try functools.partial to reduce the repetition:

from functools import partial

from django import forms


HTTPSURLField = partial(forms.URLField, assume_scheme="https")


class CheckForm(forms.Form):
    url = HTTPSURLField(label="URL to check")

(See my previous posts on using partial in Django: three uses, and three more uses.)

ModelForm classes with formfield_callback

ModelForm classes are a bit more tricky since their fields are automatically created “behind the scenes” from model fields. You can override these with explicit form fields, but that loses the automatic synchronization of other field arguments, such as max_length.

Instead, use formfield_callback, added in Django 4.2. This callback can wrap the creation of form fields to make adjustments. Use it to add assume_scheme="https" to URLField instances like so:

from django import forms
from django.db import models

from example.models import NicheMuseum


def urlfields_assume_https(db_field, **kwargs):
    """
    ModelForm.Meta.formfield_callback function to assume HTTPS for scheme-less
    domains in URLFields.
    """
    if isinstance(db_field, models.URLField):
        kwargs["assume_scheme"] = "https"
    return db_field.formfield(**kwargs)


class NicheMuseumForm(forms.ModelForm):
    class Meta:
        model = NicheMuseum
        fields = ["name", "website"]
        formfield_callback = urlfields_assume_https

For forms that already use formfield_callback, you’ll need to merge functions as appropriate.

ModelAdmin form field method override

If you’re using Django’s admin site, that’s another source of outdated URLField instances. ModelAdmin classes automatically generate ModelForm classes, unless a form is explicitly declared. Because this form class is created per-request, the warning won’t appear at import time, but when you load the related “add” or “change” pages:

$ python -Xdev manage.py runserver
Watching for file changes with StatReloader
Performing system checks...

...
/.../django/db/models/fields/__init__.py:1140: RemovedInDjango60Warning: The default scheme will be changed from 'http' to 'https' in Django 6.0. Pass the forms.URLField.assume_scheme argument to silence this warning.
  return form_class(**defaults)
[25/Nov/2023 16:37:34] "GET /admin/example/nichemuseum/add/ HTTP/1.1" 200 8907
...

You can also expose the warnings in your test suite by testing all of your ModelAdmin classes. See my previous post on using parameterized tests to do this easily.

The easiest way to fix these warnings that I know of is with the undocumented ModelAdmin.formfield_for_dbfield() method. This is passed as formfield_callback on the generated ModelForm class and applies some admin-specific customizations. Override it in custom admin classes that you then use as bases in your project:

from django.contrib import admin
from django.contrib.admin.options import BaseModelAdmin
from django.db import models

from example.models import NicheMuseum

# Custom admin classes for project-level modifications


class AdminMixin(BaseModelAdmin):
    def formfield_for_dbfield(self, db_field, request, **kwargs):
        """
        Assume HTTPS for scheme-less domains pasted into URLFields.
        """
        if isinstance(db_field, models.URLField):
            kwargs["assume_scheme"] = "https"
        return super().formfield_for_dbfield(db_field, request, **kwargs)


class ModelAdmin(AdminMixin, admin.ModelAdmin):
    pass


class StackedInline(AdminMixin, admin.StackedInline):
    pass


class TabularInline(AdminMixin, admin.TabularInline):
    pass


# Admin classes


@admin.register(NicheMuseum)
class NicheMuseumAdmin(ModelAdmin):
    ...

Third-party packages

URLField instances in third-party packages may be hard to fix in your project. Some packages do allow you to replace their form classes, so you can use the above techniques. Others don’t, so you may need to resort to monkey-patching.

In either case, projects will hopefully accept pull requests to adopt assume_scheme="https", if appropriate. Packages can pass the argument on Django 5.0+ by using this pattern:

from functools import partial

import django
from django import forms

if django.VERSION >= (5, 0):
    URLField = partial(forms.URLField, assume_scheme="https")
else:
    URLField = forms.URLField


class CheckForm(forms.Form):
    url = URLField(label="URL to check")

My tool django-upgrade rewrites such blocks to remove outdated branches. This smooths future maintenance when the package supports Django 5.0 as a minimum.

Fin

Thanks to Coen van der Kamp for opening and working on Ticket #34380 to remove this crusty assumption. And thanks to David Wobrock, Jonathan Sundqvist, and Mariusz Felisiak for reviewing it.

Whilst drafting this post a week before Django 5.0’s final release, I noticed how burdensome adopting all URLFields would be for most projects. I created a forum post raising this issue. That discussion led to Mariusz Felisiak writing a PR that added the FORMS_URLFIELD_ASSUME_HTTPS setting. Thanks to all who participated in the discussion and review.

May you be secure by default,

—Adam


Read my book Boost Your Git DX to Git better.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Tags: