Automatically upgrade your Django project code.
Improve your code quality with my book Boost Your Django DX which covers using pre-commit, django-upgrade, and many other tools. I wrote django-upgrade whilst working on the book!
Use pip:
python -m pip install django-upgrade
Python 3.8 to 3.12 supported.
(Python 3.12+ is required to correctly apply fixes within f-strings.)
You can also install django-upgrade as a pre-commit hook.
Add the following to the repos
section of your .pre-commit-config.yaml
file (docs), above any code formatters (such as Black):
- repo: https://github.com/adamchainz/django-upgrade
rev: "" # replace with latest tag on GitHub
hooks:
- id: django-upgrade
args: [--target-version, "5.0"] # Replace with Django version
Then, upgrade your entire project:
pre-commit run django-upgrade --all-files
Commit any changes. In the process, your other hooks will run, potentially reformatting django-upgrade’s changes to match your project’s code style.
Keep the hook installed in order to upgrade all code added to your project.
pre-commit’s autoupdate
command will also let you take advantage of future django-upgrade features.
django-upgrade
is a commandline tool that rewrites files in place.
Pass your Django version as <major>.<minor>
to the --target-version
flag and a list of files.
django-upgrade’s fixers will rewrite your code to avoid DeprecationWarning
s and use some new features.
For example:
django-upgrade --target-version 5.0 example/core/models.py example/settings.py
django-upgrade
focuses on upgrading your code and not on making it look nice.
Run django-upgrade before formatters like Black.
Some of django-upgrade’s fixers make changes to models that need migrations:
index_together
null_boolean_field
Add a test for pending migrations to ensure that you do not miss these.
django-upgrade
does not have any ability to recurse through directories.
Use the pre-commit integration, globbing, or another technique for applying to many files.
Some fixers depend on the names of containing directories to activate, so ensure you run django-upgrade with paths relative to the root of your project.
For example, with git ls-files | xargs
:
git ls-files -z -- '*.py' | xargs -0 django-upgrade --target-version 5.0
…or PowerShell’s ForEach-Object
:
git ls-files -- '*.py' | %{django-upgrade --target-version 5.0 $_}
The full list of fixers is documented below.
The version of Django to target, in the format <major>.<minor>
.
django-upgrade enables all of its fixers for versions up to and including the target version.
This option defaults to 2.2, the oldest supported version when this project was created.
See the list of available versions with django-upgrade --help
.
Exit with a zero return code even if files have changed. By default, django-upgrade uses the failure return code 1 if it changes any files, which may stop scripts or CI pipelines.
Run only the named fixer (names are documented below).
The fixer must still be enabled by --target-version
.
Select multiple fixers with multiple --only
options.
For example:
django-upgrade --target-version 5.0 --only admin_allow_tags --only admin_decorators example/core/admin.py
Skip the named fixer.
Skip multiple fixers with multiple --skip
options.
For example:
django-upgrade --target-version 5.0 --skip admin_register example/core/admin.py
List all available fixers’ names and then exit. All other options are ignored when listing fixers.
For example:
django-upgrade --list-fixers
django-codemod is a pre-existing, more complete Django auto-upgrade tool, written by Bruno Alla. Unfortunately its underlying library LibCST is particularly slow, making it annoying to run django-codemod on every commit and in CI.
django-upgrade is an experiment in reimplementing such a tool using the same techniques as the fantastic pyupgrade. The tool leans on the standard library’s ast and tokenize modules, the latter via the tokenize-rt wrapper. This means it will always be fast and support the latest versions of Python.
For a quick benchmark: running django-codemod against a medium Django repository with 153k lines of Python takes 133 seconds. pyupgrade and django-upgrade both take less than 0.5 seconds.
The below fixers run regardless of the target version.
Name: versioned_branches
Removes outdated comparisons and blocks from if
statements comparing to django.VERSION
.
Supports comparisons of the form:
if django.VERSION <comparator> (<X>, <Y>):
...
Where <comparator>
is one of <
, <=
, >
, or >=
, and <X>
and <Y>
are integer literals.
A single else
block may be present, but elif
is not supported.
-if django.VERSION < (4, 1):
- class RenameIndex:
- ...
-if django.VERSION >= (4, 1):
- constraint.validate()
-else:
- custom_validation(constraint)
+constraint.validate()
See also pyupgrade’s similar feature that removes outdated code from checks on the Python version.
Name: check_constraint_condition
Rewrites calls to CheckConstraint
and built-in subclasses from the old check
argument to the new name condition
.
Requires Python 3.9+ due to changes in ast.keyword
.
-CheckConstraint(check=Q(amount__gte=0))
+CheckConstraint(condition=Q(amount__gte=0))
No fixers yet.
Name: settings_storages
Combines deprecated settings DEFAULT_FILE_STORAGE
and STATICFILES_STORAGE
into the new STORAGES
setting, within settings files.
Only applies if all old settings are defined as strings, at module level, and a STORAGES
setting hasn’t been defined.
Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For example myproject/settings.py
or myproject/settings/production.py
.
-DEFAULT_FILE_STORAGE = "example.storages.ExtendedFileSystemStorage"
-STATICFILES_STORAGE = "example.storages.ExtendedS3Storage"
+STORAGES = {
+ "default": {
+ "BACKEND": "example.storages.ExtendedFileSystemStorage",
+ },
+ "staticfiles": {
+ "BACKEND": "example.storages.ExtendedS3Storage",
+ },
+}
If the module has a from ... import *
with a module path mentioning “settings”, django-upgrade makes an educated guess that a base STORAGES
setting is imported from there.
It then uses **
to extend that with any values in the current module:
from example.settings.base import *
-DEFAULT_FILE_STORAGE = "example.storages.S3Storage"
+STORAGES = {
+ **STORAGES,
+ "default": {
+ "BACKEND": "example.storages.S3Storage",
+ },
+}
Name: test_http_headers
Transforms HTTP headers from the old WSGI kwarg format to use the new headers
dictionary, for:
Client
method likeself.client.get()
Client
instantiationRequestFactory
instantiation
Requires Python 3.9+ due to changes in ast.keyword
.
-response = self.client.get("/", HTTP_ACCEPT="text/plain")
+response = self.client.get("/", headers={"accept": "text/plain"})
from django.test import Client
-Client(HTTP_ACCEPT_LANGUAGE="fr-fr")
+Client(headers={"accept-language": "fr-fr"})
from django.test import RequestFactory
-RequestFactory(HTTP_USER_AGENT="curl")
+RequestFactory(headers={"user-agent": "curl"})
Name: index_together
Rewrites index_together
declarations into indexes
declarations in model Meta
classes.
from django.db import models
class Duck(models.Model):
class Meta:
- index_together = [["bill", "tail"]]
+ indexes = [models.Index(fields=["bill", "tail"])]
Name: assert_set_methods
Rewrites calls to these test case methods from the old names to the new ones with capitalized “Set”.
-self.assertFormsetError(response.context["form"], "username", ["Too long"])
+self.assertFormSetError(response.context["form"], "username", ["Too long"])
-self.assertQuerysetEqual(authors, ["Brad Dayley"], lambda a: a.name)
+self.assertQuerySetEqual(authors, ["Brad Dayley"], lambda a: a.name)
Name: utils_timezone
Rewrites imports of django.utils.timezone.utc
to use datetime.timezone.utc
.
Requires an existing import of the datetime
module.
import datetime
-from django.utils.timezone import utc
-calculate_some_datetime(utc)
+calculate_some_datetime(datetime.timezone.utc)
import datetime as dt
from django.utils import timezone
-do_a_thing(timezone.utc)
+do_a_thing(dt.timezone.utc)
Name: assert_form_error
Rewrites calls to these test case methods from the old signatures to the new ones.
-self.assertFormError(response, "form", "username", ["Too long"])
+self.assertFormError(response.context["form"], "username", ["Too long"])
-self.assertFormError(response, "form", "username", None)
+self.assertFormError(response.context["form"], "username", [])
-self.assertFormsetError(response, "formset", 0, "username", ["Too long"])
+self.assertFormsetError(response.context["formset"], 0, "username", ["Too long"])
-self.assertFormsetError(response, "formset", 0, "username", None)
+self.assertFormsetError(response.context["formset"], 0, "username", [])
Name: use_l10n
Removes the deprecated USE_L10N
setting if set to its default value of True
.
Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For example myproject/settings.py
or myproject/settings/production.py
.
-USE_L10N = True
Name: admin_lookup_needs_distinct
Renames the undocumented django.contrib.admin.utils.lookup_needs_distinct
to lookup_spawns_duplicates
:
-from django.contrib.admin.utils import lookup_needs_distinct
+from django.contrib.admin.utils import lookup_spawns_duplicates
-if lookup_needs_distinct(self.opts, search_spec):
+if lookup_spawns_duplicates(self.opts, search_spec):
...
Rewrites some compatibility imports:
django.utils.translation.template.TRANSLATOR_COMMENT_MARK
indjango.template.base
-from django.template.base import TRANSLATOR_COMMENT_MARK
+from django.utils.translation.template import TRANSLATOR_COMMENT_MARK
Name: admin_decorators
Rewrites functions that have admin action attributes assigned to them to use the new @admin.action()
decorator.
This only applies in files that use from django.contrib import admin
or from django.contrib.gis import admin
.
from django.contrib import admin
# Module-level actions:
+@admin.action(
+ description="Publish articles",
+)
def make_published(modeladmin, request, queryset):
...
-make_published.short_description = "Publish articles"
# …and within classes:
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
+ @admin.action(
+ description="Unpublish articles",
+ permissions=("unpublish",),
+ )
def make_unpublished(self, request, queryset):
...
- make_unpublished.allowed_permissions = ("unpublish",)
- make_unpublished.short_description = "Unpublish articles"
Name: admin_decorators
Rewrites functions that have admin display attributes assigned to them to use the new @admin.display()
decorator.
This only applies in files that use from django.contrib import admin
or from django.contrib.gis import admin
.
from django.contrib import admin
# Module-level display functions:
+@admin.display(
+ description="NAME",
+)
def upper_case_name(obj):
...
-upper_case_name.short_description = "NAME"
# …and within classes:
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
+ @admin.display(
+ description='Is Published?',
+ boolean=True,
+ ordering='-publish_date',
+ )
def is_published(self, obj):
...
- is_published.boolean = True
- is_published.admin_order_field = '-publish_date'
- is_published.short_description = 'Is Published?'
Name: management_commands
Rewrites the requires_system_checks
attributes of management command classes from bools to "__all__"
or []
as appropriate.
This only applies in command files, which are heuristically detected as files with management/commands
somewhere in their path.
from django.core.management.base import BaseCommand
class Command(BaseCommand):
- requires_system_checks = True
+ requires_system_checks = "__all__"
class SecondCommand(BaseCommand):
- requires_system_checks = False
+ requires_system_checks = []
Name: email_validator
Rewrites the whitelist
keyword argument to its new name allowlist
.
from django.core.validators import EmailValidator
-EmailValidator(whitelist=["example.com"])
+EmailValidator(allowlist=["example.com"])
Name: default_app_config
Removes module-level default_app_config
assignments from __init__.py
files:
-default_app_config = 'my_app.apps.AppConfig'
Name: compatibility_imports
Rewrites imports of JSONField
and related transform classes from those in django.contrib.postgres
to the new all-database versions.
Ignores usage in migration files, since Django kept the old class around to support old migrations.
You will need to make migrations after this fix makes changes to models.
-from django.contrib.postgres.fields import JSONField
+from django.db.models import JSONField
Name: password_reset_timeout_days
Rewrites the setting PASSWORD_RESET_TIMEOUT_DAYS
to PASSWORD_RESET_TIMEOUT
, adding the multiplication by the number of seconds in a day.
Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For example myproject/settings.py
or myproject/settings/production.py
.
-PASSWORD_RESET_TIMEOUT_DAYS = 4
+PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 4
Name: signal_providing_args
Removes the deprecated documentation-only providing_args
argument.
from django.dispatch import Signal
-my_cool_signal = Signal(providing_args=["documented", "arg"])
+my_cool_signal = Signal()
Name: crypto_get_random_string
Injects the now-required length
argument, with its previous default 12
.
from django.utils.crypto import get_random_string
-key = get_random_string(allowed_chars="01234567899abcdef")
+key = get_random_string(length=12, allowed_chars="01234567899abcdef")
Name: null_boolean_field
Transforms the NullBooleanField()
model field to BooleanField(null=True)
.
Applied only in model files, not migration files, since Django kept the old class around to support old migrations.
You will need to make migrations after this fix makes changes to models.
-from django.db.models import Model, NullBooleanField
+from django.db.models import Model, BooleanField
class Book(Model):
- valuable = NullBooleanField("Valuable")
+ valuable = BooleanField("Valuable", null=True)
Name: forms_model_multiple_choice_field
Replace list
error message key with list_invalid
on forms ModelMultipleChoiceField
.
-forms.ModelMultipleChoiceField(error_messages={"list": "Enter multiple values."})
+forms.ModelMultipleChoiceField(error_messages={"invalid_list": "Enter multiple values."})
Name: utils_encoding
Rewrites smart_text()
to smart_str()
, and force_text()
to force_str()
.
-from django.utils.encoding import force_text, smart_text
+from django.utils.encoding import force_str, smart_str
-force_text("yada")
-smart_text("yada")
+force_str("yada")
+smart_str("yada")
Name: utils_http
:
Rewrites the urlquote()
, urlquote_plus()
, urlunquote()
, and urlunquote_plus()
functions to the urllib.parse
versions.
Also rewrites the internal function is_safe_url()
to url_has_allowed_host_and_scheme()
.
-from django.utils.http import urlquote
+from urllib.parse import quote
-escaped_query_string = urlquote(query_string)
+escaped_query_string = quote(query_string)
Name: utils_text
Rewrites unescape_entities()
with the standard library html.escape()
.
-from django.utils.text import unescape_entities
+import html
-unescape_entities("some input string")
+html.escape("some input string")
Name: utils_translation
Rewrites the ugettext()
, ugettext_lazy()
, ugettext_noop()
, ungettext()
, and ungettext_lazy()
functions to their non-u-prefixed versions.
-from django.utils.translation import ugettext as _, ungettext
+from django.utils.translation import gettext as _, ngettext
-ungettext("octopus", "octopodes", n)
+ngettext("octopus", "octopodes", n)
Name: request_headers
Rewrites use of request.META
to read HTTP headers to instead use request.headers
.
Header lookups are done in lowercase per the HTTP/2 specification.
-request.META['HTTP_ACCEPT_ENCODING']
+request.headers['accept-encoding']
-self.request.META.get('HTTP_SERVER', '')
+self.request.headers.get('server', '')
-request.META.get('CONTENT_LENGTH')
+request.headers.get('content-length')
-"HTTP_SERVER" in request.META
+"server" in request.headers
Name: queryset_paginator
Rewrites deprecated alias django.core.paginator.QuerySetPaginator
to Paginator
.
-from django.core.paginator import QuerySetPaginator
+from django.core.paginator import Paginator
-QuerySetPaginator(...)
+Paginator(...)
Name: timezone_fixedoffset
Rewrites deprecated class FixedOffset(x, y))
to timezone(timedelta(minutes=x), y)
Known limitation: this fixer will leave code broken with an ImportError
if FixedOffset
is called with only *args
or **kwargs
.
-from django.utils.timezone import FixedOffset
-FixedOffset(120, "Super time")
+from datetime import timedelta, timezone
+timezone(timedelta(minutes=120), "Super time")
Name: postgres_float_range_field
Rewrites model and form fields using FloatRangeField
to DecimalRangeField
, from the relevant django.contrib.postgres
modules.
from django.db.models import Model
-from django.contrib.postgres.fields import FloatRangeField
+from django.contrib.postgres.fields import DecimalRangeField
class MyModel(Model):
- my_field = FloatRangeField("My range of numbers")
+ my_field = DecimalRangeField("My range of numbers")
Name: testcase_databases
Rewrites the allow_database_queries
and multi_db
attributes of Django’s TestCase
classes to the new databases
attribute.
This only applies in test files, which are heuristically detected as files with either “test” or “tests” somewhere in their path.
Note that this will only rewrite to databases = []
or databases = "__all__"
.
With multiple databases you can save some test time by limiting test cases to the databases they require (which is why Django made the change).
from django.test import SimpleTestCase
class MyTests(SimpleTestCase):
- allow_database_queries = True
+ databases = "__all__"
def test_something(self):
self.assertEqual(2 * 2, 4)
No fixers yet.
Name: django_urls
Rewrites imports of include()
and url()
from django.conf.urls
to django.urls
.
url()
calls using compatible regexes are rewritten to the new path()
syntax, otherwise they are converted to call re_path()
.
-from django.conf.urls import include, url
+from django.urls import include, path, re_path
urlpatterns = [
- url(r'^$', views.index, name='index'),
+ path('', views.index, name='index'),
- url(r'^about/$', views.about, name='about'),
+ path('about/', views.about, name='about'),
- url(r'^post/(?P<slug>[-a-zA-Z0-9_]+)/$', views.post, name='post'),
+ path('post/<slug:slug>/', views.post, name='post'),
- url(r'^weblog', include('blog.urls')),
+ re_path(r'^weblog', include('blog.urls')),
]
Existing re_path()
calls are also rewritten to the path()
syntax when eligible.
-from django.urls import include, re_path
+from django.urls import include, path, re_path
urlpatterns = [
- re_path(r'^about/$', views.about, name='about'),
+ path('about/', views.about, name='about'),
re_path(r'^post/(?P<slug>[\w-]+)/$', views.post, name='post'),
]
The compatible regexes that will be converted to use path converters are the following:
[^/]+
→str
[0-9]+
→int
[-a-zA-Z0-9_]+
→slug
[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}
→uuid
.+
→path
These are taken from the path converter classes.
For some cases, this change alters the type of the arguments passed to the view, from str
to the converted type (e.g. int
).
This is not guaranteed backwards compatible: there is a chance that the view expects a string, rather than the converted type.
But, pragmatically, it seems 99.9% of views do not require strings, and instead work with either strings or the converted type.
Thus, you should test affected paths after this fixer makes any changes.
Note that [\w-]
is sometimes used for slugs, but is not converted because it might be incompatible.
That pattern matches all Unicode word characters, such as “α”, unlike Django's slug
converter, which only matches Latin characters.
Name: compatibility_imports
Rewrites imports of lru_cache
from django.utils.functional
to use functools
.
-from django.utils.functional import lru_cache
+from functools import lru_cache
Rewrites imports of ContextDecorator
from django.utils.decorators
to use contextlib
.
-from django.utils.decorators import ContextDecorator
+from contextlib import ContextDecorator
Name: admin_allow_tags
Removes assignments of allow_tags
attributes to True
.
This was an admin feature to allow display functions to return HTML without marking it as unsafe, deprecated in Django 1.9.
In practice, most display functions that return HTML already use format_html()
or similar, so the attribute wasn’t necessary.
This only applies in files that use from django.contrib import admin
or from django.contrib.gis import admin
.
from django.contrib import admin
def upper_case_name(obj):
...
-upper_case_name.allow_tags = True
Name: compatibility_imports
Rewrites some compatibility imports:
django.core.exceptions.EmptyResultSet
indjango.db.models.query
,django.db.models.sql
, anddjango.db.models.sql.datastructures
django.core.exceptions.FieldDoesNotExist
indjango.db.models.fields
Whilst mentioned in the Django 3.1 release notes, these have been possible since Django 1.11.
-from django.db.models.query import EmptyResultSet
+from django.core.exceptions import EmptyResultSet
-from django.db.models.fields import FieldDoesNotExist
+from django.core.exceptions import FieldDoesNotExist
Name: request_user_attributes
Rewrites calls to request.user.is_authenticated()
and request.user.is_anonymous()
to remove the parentheses, per the deprecation.
-request.user.is_authenticated()
+request.user.is_authenticated
-self.request.user.is_anonymous()
+self.request.user.is_anonymous
Rewrites some compatibility imports:
django.templatetags.static.static
indjango.contrib.staticfiles.templatetags.staticfiles
(Whilst mentioned in the Django 2.1 release notes, this has been possible since Django 1.10.)
django.urls.*
indjango.core.urlresolvers.*
-from django.contrib.staticfiles.templatetags.staticfiles import static
+from django.templatetags.static import static
-from django.core.urlresolvers import reverse
+from django.urls import reverse
-from django.core.urlresolvers import resolve
+from django.urls import resolve
Name: on_delete
Add on_delete=models.CASCADE
to ForeignKey
and OneToOneField
:
from django.db import models
-models.ForeignKey("auth.User")
+models.ForeignKey("auth.User", on_delete=models.CASCADE)
-models.OneToOneField("auth.User")
+models.OneToOneField("auth.User", on_delete=models.CASCADE)
This fixer also support from-imports:
-from django.db.models import ForeignKey
+from django.db.models import CASCADE, ForeignKey
-ForeignKey("auth.User")
+ForeignKey("auth.User", on_delete=CASCADE)
Name: settings_database_postgresql
Update the DATABASES
setting backend path django.db.backends.postgresql_psycopg2
to use the renamed version django.db.backends.postgresql
.
Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For example myproject/settings.py
or myproject/settings/production.py
.
DATABASES = {
"default": {
- "ENGINE": "django.db.backends.postgresql_psycopg2",
+ "ENGINE": "django.db.backends.postgresql",
"NAME": "mydatabase",
"USER": "mydatabaseuser",
"PASSWORD": "mypassword",
"HOST": "127.0.0.1",
"PORT": "5432",
}
}
Name: compatibility_imports
Rewrites some compatibility imports:
django.forms.utils.pretty_name
indjango.forms.forms
django.forms.boundfield.BoundField
indjango.forms.forms
django.forms.widgets.SelectDateWidget
indjango.forms.extras
Whilst mentioned in the Django 3.1 release notes, these have been possible since Django 1.9.
-from django.forms.forms import pretty_name
+from django.forms.utils import pretty_name
No fixers yet.
Name: admin_register
Rewrites admin.site.register()
calls to the new @admin.register()
decorator syntax when eligible.
This only applies in files that use from django.contrib import admin
or from django.contrib.gis import admin
.
from django.contrib import admin
+@admin.register(MyModel1, MyModel2)
class MyCustomAdmin(admin.ModelAdmin):
...
-admin.site.register(MyModel1, MyCustomAdmin)
-admin.site.register(MyModel2, MyCustomAdmin)
This also works with custom admin sites. Such calls are detected heuristically based on three criteria:
- The object whose
register()
method is called has a name ending withsite
. - The registered class has a name ending with
Admin
. - The filename has the word
admin
somewhere in its path.
from myapp.admin import custom_site
from django.contrib import admin
+@admin.register(MyModel)
+@admin.register(MyModel, site=custom_site)
class MyModelAdmin(admin.ModelAdmin):
pass
-custom_site.register(MyModel, MyModelAdmin)
-admin.site.register(MyModel, MyModelAdmin)
If a register()
call is preceded by an unregister()
call that includes the same model, it is ignored.
from django.contrib import admin
class MyCustomAdmin(admin.ModelAdmin):
...
admin.site.unregister(MyModel1)
admin.site.register(MyModel1, MyCustomAdmin)
Rewrites some compatibility imports:
django.contrib.admin.helpers.ACTION_CHECKBOX_NAME
indjango.contrib.admin
django.template.context.BaseContext
,django.template.context.Context
,django.template.context.ContextPopException
anddjango.template.context.RequestContext
indjango.template.base
-from django.contrib.admin import ACTION_CHECKBOX_NAME
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
-from django.template.base import Context
+from django.template.context import Context