Package: django-upgrade
Automatically upgrade your Django project code.Improve your code qualitywith my bookBoost Your Django DXwhich covers using  pre-commit, django-upgrade, and many other tools.
I wrote django-upgrade whilst working on the book!InstallationUsepip:python-mpipinstalldjango-upgradePython 3.8 to 3.12 supported.(Python 3.12+ is required to correctly apply fixes within f-strings.)pre-commit hookYou can also install django-upgrade as apre-commithook.
Add the following to therepossection of your.pre-commit-config.yamlfile (docs), above any code formatters (such as Black):-repo:https://github.com/adamchainz/django-upgraderev:""# replace with latest tag on GitHubhooks:-id:django-upgradeargs:[--target-version,"4.1"]# Replace with Django versionThen, upgrade your entire project:pre-commitrundjango-upgrade--all-filesCommit 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’sautoupdatecommand will also let you take advantage of future django-upgrade features.Usagedjango-upgradeis a commandline tool that rewrites files in place.
Pass your Django version as<major>.<minor>to the--target-versionflag.
django-upgrade will run all its fixers for versions up to and including the target version.
These fixers rewrite your code to avoidDeprecationWarnings and use some new features.For example:django-upgrade--target-version4.1example/core/models.pyexample/settings.pyThe--target-versionflag defaults to 2.2, the oldest supported version when this project was created.
For more on usage rundjango-upgrade--help.django-upgradefocuses on upgrading your code and not on making it look nice.
Run django-upgrade before formatters likeBlack.django-upgradedoes 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,withgitls-files| xargs:gitls-files-z--'*.py'|xargs-0django-upgrade--target-version4.1…or PowerShell’sForEach-Object:gitls-files--'*.py'|%{django-upgrade--target-version4.1$_}The full list of fixers is documented below.Historydjango-codemodis a pre-existing, more complete Django auto-upgrade tool, written by Bruno Alla.
Unfortunately its underlying libraryLibCSTis 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 fantasticpyupgrade.
The tool leans on the standard library’sastandtokenizemodules, the latter via thetokenize-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.FixersAll VersionsThe below fixers run regardless of the target version.Versioned blocksRemoves outdated comparisons and blocks fromifstatements comparing todjango.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 singleelseblock may be present, butelifis not supported.-if django.VERSION < (4, 1):-    class RenameIndex:-        ...-if django.VERSION >= (4, 1):-    constraint.validate()-else:-    custom_validation(constraint)+constraint.validate()See alsopyupgrade’s similar featurethat removes outdated code from checks on the Python version.Django 1.7Release NotesAdmin model registrationRewritesadmin.site.register()calls to the new@admin.register()decorator syntax when eligible.
This only applies in files that usefrom django.contrib import adminorfrom 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 whoseregister()method is called has a name ending withsite.The registered class has a name ending withAdmin.The filename has the wordadminsomewhere in its path.from myapp.admin import custom_sitefrom 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 aregister()call is preceded by anunregister()call that includes the same model, it is ignored.fromdjango.contribimportadminclassMyCustomAdmin(admin.ModelAdmin):...admin.site.unregister(MyModel1)admin.site.register(MyModel1,MyCustomAdmin)Compatibility importsRewrites some compatibility imports:django.contrib.admin.helpers.ACTION_CHECKBOX_NAMEindjango.contrib.admindjango.template.context.BaseContext,django.template.context.Context,django.template.context.ContextPopExceptionanddjango.template.context.RequestContextindjango.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 ContextDjango 1.9Release Noteson_deleteargumentAddon_delete=models.CASCADEtoForeignKeyandOneToOneField: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)DATABASESUpdate theDATABASESsetting backend pathdjango.db.backends.postgresql_psycopg2to use the renamed versiondjango.db.backends.postgresql.Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For examplemyproject/settings.pyormyproject/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",}}Compatibility importsRewrites some compatibility imports:django.forms.utils.pretty_nameindjango.forms.formsdjango.forms.boundfield.BoundFieldindjango.forms.formsdjango.forms.widgets.SelectDateWidgetindjango.forms.extrasWhilst mentioned in theDjango 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_nameDjango 1.10Release Notesrequest.userboolean attributesRewrites calls torequest.user.is_authenticated()andrequest.user.is_anonymous()to remove the parentheses, perthe deprecation.-request.user.is_authenticated()+request.user.is_authenticated-self.request.user.is_anonymous()+self.request.user.is_anonymousCompatibility importsRewrites some compatibility imports:django.templatetags.static.staticindjango.contrib.staticfiles.templatetags.staticfiles(Whilst mentioned in theDjango 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 resolveDjango 1.11Release NotesCompatibility importsRewrites some compatibility imports:django.core.exceptions.EmptyResultSetindjango.db.models.query,django.db.models.sql, anddjango.db.models.sql.datastructuresdjango.core.exceptions.FieldDoesNotExistindjango.db.models.fieldsWhilst mentioned in theDjango 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 FieldDoesNotExistDjango 2.0Release NotesURL’sRewrites imports ofinclude()andurl()fromdjango.conf.urlstodjango.urls.url()calls using compatible regexes are rewritten to thenewpath()syntax, otherwise they are converted to callre_path().-from django.conf.urls import include, url+from django.urls import include, path, re_pathurlpatterns = [-    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')),]Existingre_path()calls are also rewritten to thepath()syntax when eligible.-from django.urls import include, re_path+from django.urls import include, path, re_pathurlpatterns = [-    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 usepath convertersare 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.+→pathThese are taken from the path converter classes.For some cases, this change alters the type of the arguments passed to the view, fromstrto 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’sslugconverter, which only matches Latin characters.lru_cacheRewrites imports oflru_cachefromdjango.utils.functionalto usefunctools.-from django.utils.functional import lru_cache+from functools import lru_cacheContextDecoratorRewrites imports ofContextDecoratorfromdjango.utils.decoratorsto usecontextlib.-from django.utils.decorators import ContextDecorator+from contextlib import ContextDecorator<func>.allow_tags= TrueRemoves assignments ofallow_tagsattributes toTrue.
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 useformat_html()or similar, so the attribute wasn’t necessary.
This only applies in files that usefrom django.contrib import adminorfrom django.contrib.gis import admin.from django.contrib import admindef upper_case_name(obj):...-upper_case_name.allow_tags = TrueDjango 2.2Release NotesHttpRequest.headersRewrites use ofrequest.METAto read HTTP headers to instead userequest.headers.
Header lookups are done in lowercase perthe 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.headersQuerySetPaginatorRewrites deprecated aliasdjango.core.paginator.QuerySetPaginatortoPaginator.-from django.core.paginator import QuerySetPaginator+from django.core.paginator import Paginator-QuerySetPaginator(...)+Paginator(...)FixedOffsetRewrites deprecated classFixedOffset(x, y))totimezone(timedelta(minutes=x), y)Known limitation: this fixer will leave code broken with anImportErrorifFixedOffsetis called with only*argsor**kwargs.-from django.utils.timezone import FixedOffset-FixedOffset(120, "Super time")+from datetime import timedelta, timezone+timezone(timedelta(minutes=120), "Super time")FloatRangeFieldRewrites model and form fields usingFloatRangeFieldtoDecimalRangeField, from the relevantdjango.contrib.postgresmodules.from django.db.models import Model-from django.contrib.postgres.fields import FloatRangeField+from django.contrib.postgres.fields import DecimalRangeFieldclass MyModel(Model):-    my_field = FloatRangeField("My range of numbers")+    my_field = DecimalRangeField("My range of numbers")TestCaseclass database declarationsRewrites theallow_database_queriesandmulti_dbattributes of Django’sTestCaseclasses to the newdatabasesattribute.
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 todatabases = []ordatabases = "__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 SimpleTestCaseclass MyTests(SimpleTestCase):-    allow_database_queries = True+    databases = "__all__"def test_something(self):self.assertEqual(2 * 2, 4)Django 3.0Release Notesdjango.utils.encodingaliasesRewritessmart_text()tosmart_str(), andforce_text()toforce_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")django.utils.httpdeprecationsRewrites theurlquote(),urlquote_plus(),urlunquote(), andurlunquote_plus()functions to theurllib.parseversions.
Also rewrites the internal functionis_safe_url()tourl_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)django.utils.textdeprecationRewritesunescape_entities()with the standard libraryhtml.escape().-from django.utils.text import unescape_entities+import html-unescape_entities("some input string")+html.escape("some input string")django.utils.translationdeprecationsRewrites theugettext(),ugettext_lazy(),ugettext_noop(),ungettext(), andungettext_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)Django 3.1Release NotesJSONFieldRewrites imports ofJSONFieldand related transform classes from those indjango.contrib.postgresto 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 JSONFieldPASSWORD_RESET_TIMEOUT_DAYSRewrites the settingPASSWORD_RESET_TIMEOUT_DAYStoPASSWORD_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 examplemyproject/settings.pyormyproject/settings/production.py.-PASSWORD_RESET_TIMEOUT_DAYS = 4+PASSWORD_RESET_TIMEOUT = 60 * 60 * 24 * 4SignalRemoves the deprecated documentation-onlyproviding_argsargument.from django.dispatch import Signal-my_cool_signal = Signal(providing_args=["documented", "arg"])+my_cool_signal = Signal()get_random_stringInjects the now-requiredlengthargument, with its previous default12.from django.utils.crypto import get_random_string-key = get_random_string(allowed_chars="01234567899abcdef")+key = get_random_string(length=12, allowed_chars="01234567899abcdef")NullBooleanFieldTransforms theNullBooleanField()model field toBooleanField(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, BooleanFieldclass Book(Model):-    valuable = NullBooleanField("Valuable")+    valuable = BooleanField("Valuable", null=True)ModelMultipleChoiceFieldReplacelisterror message key withlist_invalidon formsModelMultipleChoiceField.-forms.ModelMultipleChoiceField(error_messages={"list": "Enter multiple values."})+forms.ModelMultipleChoiceField(error_messages={"invalid_list": "Enter multiple values."})Django 3.2Release Notes@admin.action()Rewrites functions that have admin action attributes assigned to them to use the new@admin.action()decorator.
This only applies in files that usefrom django.contrib import adminorfrom 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"@admin.display()Rewrites functions that have admin display attributes assigned to them to use the new@admin.display()decorator.
This only applies in files that usefrom django.contrib import adminorfrom 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?'BaseCommand.requires_system_checksRewrites therequires_system_checksattributes of management command classes from bools to"__all__"or[]as appropriate.
This only applies in command files, which are heuristically detected as files withmanagement/commandssomewhere in their path.from django.core.management.base import BaseCommandclass Command(BaseCommand):-    requires_system_checks = True+    requires_system_checks = "__all__"class SecondCommand(BaseCommand):-    requires_system_checks = False+    requires_system_checks = []EmailValidatorRewrites thewhitelistkeyword argument to its new nameallowlist.from django.core.validators import EmailValidator-EmailValidator(whitelist=["example.com"])+EmailValidator(allowlist=["example.com"])default_app_configRemoves module-leveldefault_app_configassignments from__init__.pyfiles:-default_app_config = 'my_app.apps.AppConfig'Django 4.0Release NotesUSE_L10NRemoves the deprecatedUSE_L10Nsetting if set to its default value ofTrue.Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For examplemyproject/settings.pyormyproject/settings/production.py.-USE_L10N = Truelookup_needs_distinctRenames the undocumenteddjango.contrib.admin.utils.lookup_needs_distincttolookup_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):...Compatibility importsRewrites some compatibility imports:django.utils.translation.template.TRANSLATOR_COMMENT_MARKindjango.template.base-from django.template.base import TRANSLATOR_COMMENT_MARK+from django.utils.translation.template import TRANSLATOR_COMMENT_MARKDjango 4.1Release Notesdjango.utils.timezone.utcdeprecationsRewrites imports ofdjango.utils.timezone.utcto usedatetime.timezone.utc.
Requires an existing import of thedatetimemodule.import datetime-from django.utils.timezone import utc-calculate_some_datetime(utc)+calculate_some_datetime(datetime.timezone.utc)import datetime as dtfrom django.utils import timezone-do_a_thing(timezone.utc)+do_a_thing(dt.timezone.utc)assertFormError()andassertFormsetError()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", [])Django 4.2Release NotesSTORAGESsettingCombines deprecated settingsDEFAULT_FILE_STORAGEandSTATICFILES_STORAGEinto the newSTORAGESsetting, within settings files.
Only applies if all old settings are defined as strings, at module level, and aSTORAGESsetting hasn’t been defined.Settings files are heuristically detected as modules with the whole word “settings” somewhere in their path.
For examplemyproject/settings.pyormyproject/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 afrom ... import *with a module path mentioning “settings”, django-upgrade makes an educated guess that a baseSTORAGESsetting 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",+    },+}Test client HTTP headersTransforms HTTP headers from the old WSGI kwarg format to use the newheadersdictionary, for:Clientmethod likeself.client.get()ClientinstantiationRequestFactoryinstantiationRequires Python 3.9+ due to changes inast.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"})assertFormsetErrorandassertQuerysetEqualRewrites 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)Django 5.0Release NotesNo fixers yet.