Chapter 17 · The utility layer

17.14 Django’s version: VersionTuple and get_version

django.VERSION is a VersionTuple, a tuple that names its parts and warns about the readings that will change meaning when Django’s versions become calendar versions, and django.__version__ is the string that get_version makes from it. Beside them, django/utils/version.py has get_docs_version, get_version_tuple and the constants PY310 to PY315.

django/__init__.py makes VERSION, (6, 2, 0, "alpha", 0) at the pin, and computes __version__ from it on import. The command django-admin version, the development server’s banner, a migration’s header and a new project’s settings print the string.

Two forms of a version

Following DEP 20, the releases from Django 2028, the one that was to be 7.0, are calendar versions, numbered by a year and a patch number (2028.1) with no minor number; What changed sets them beside the releases from 5.0 to this commit. VERSION holds the numbers as printed, then the status ("alpha", "beta", "rc" or "final") and its iteration: five parts for 6.2.1, four for 2028.1. A comment on _validate_version says that keeping the numbers as printed is what makes django.VERSION >= (2028, 3) mean what it looks like; the function checks the length and the status with assert.

The two forms agree from the right. VersionTuple.patch, VersionTuple.status and VersionTuple.iteration are the third, second and last parts from the end, and VersionTuple.feature, the numbers that name a feature release, is everything before them.

Figure 17.4 The two forms of django.VERSION aligned from the right: the attributes read the same parts in both, while the third part from the left is the patch number of 6.2.1 and the status of 2028.1.

VersionTuple is a tuple with empty __slots__, made from its parts one by one. A subclass of tuple is copied and pickled by handing the whole tuple to its constructor as one argument, which the validation refuses, so VersionTuple.__getnewargs__ hands over the parts, and copy.copy and pickle give back a VersionTuple. A class that defines __eq__ without __hash__ is unhashable; VersionTuple assigns the tuple’s __hash__.

The deprecations of 6.2

Without the minor number every later part moves down an index, so 6.2 deprecates each reading that depends on position or length: indexing the third part or later, a negative index included (even VERSION[-1], the iteration in both forms, warns), a slice that reaches the third part, iterating or unpacking, len, and comparing with a tuple of three parts or more. A comparison with one or two parts does not warn, nor do in and repr, which the class leaves to the tuple.

Each goes through VersionTuple._warn, which returns at once for a version of four parts and otherwise calls warn_about_external_use with RemovedInDjango2028Warning. Its skip_name_prefixes="django.utils.version.VersionTuple" passes over the class’s own methods, so the warning is attributed to the code that read the tuple, and is not raised when that code is Django’s: get_version unpacks the tuple in silence (Deprecation: warnings named by year, warn_about_external_use and deprecate_posargs).

# a run in a script, django.VERSION being (6, 2, 0, "alpha", 0)
django.VERSION >= (6, 2)              True
"alpha" in django.VERSION             True
django.VERSION[2]                     0        RemovedInDjango2028Warning
django.VERSION[-1]                    0        RemovedInDjango2028Warning
len(django.VERSION)                   5        RemovedInDjango2028Warning
django.VERSION >= (6, 2, 0)           True     RemovedInDjango2028Warning
major, minor, *rest = django.VERSION           RemovedInDjango2028Warning
VersionTuple(2028, 1, "final", 0)[2]  'final'

the first message:
  Indexing django.VERSION from its third component on is deprecated.
  django.VERSION has four components from Django 2028, (year, patch, status,
  iteration), as calendar versions have no minor component. Use `.feature`,
  `.patch`, `.status`, and `.iteration` attributes instead.

get_version and the strings made from a version

get_version makes a PEP 440 string. get_main_version joins the numbers before the status, the last left out if it is zero: "6.2" for (6, 2, 0, "alpha", 0), "2028" for (2028, 0, "final", 0). A suffix follows. An alpha of iteration 0, a pre-alpha in the code’s comment, as at the pin, takes ".dev" and what get_git_changeset returns; another alpha, a beta or a release candidate takes "a", "b" or "rc" and the iteration; a final release takes nothing.

get_git_changeset runs git log in the directory of the django package, so git reads whatever repository holds that directory, and formats the time of its checked-out commit in UTC, year to second. Where the output is not a number, as without git or outside a repository, it returns None and the suffix is left off; functools.lru_cache keeps it to one run a process. A pre-alpha’s version thus changes with each commit, and a pickled model instance, which carries django.__version__, warns when unpickled under another.

get_docs_version gives "dev" for a version that is not final and otherwise the feature numbers, since, a comment says, documentation is published per feature release. It goes into the links to docs.djangoproject.com in Django’s messages, such as the development server’s warning and the page shown when no URLs are configured.

# a run: versions made by hand      get_version              get_docs_version
VersionTuple(6, 2, 0, "alpha", 0)   '6.2.dev20260926051415'  'dev'
VersionTuple(6, 2, 0, "beta", 1)    '6.2b1'                  'dev'
VersionTuple(6, 2, 1, "final", 0)   '6.2.1'                  '6.2'
VersionTuple(2028, 0, "final", 0)   '2028'                   '2028'
VersionTuple(2028, 1, "final", 0)   '2028.1'                 '2028'

get_version_tuple and the Python constants

get_version_tuple reads another program’s version string as numbers, stopping at the first part that is not one: get_version_tuple("3.2.0b1") is (3, 2, 0). Django reads with it the versions of the psycopg and oracledb drivers, GEOS, SpatiaLite, PostGIS and Watchman.

PYPY and PY310 to PY315 are booleans set at import, PY314 meaning Python 3.14 or later. Comments call them a private, stable API, and keep each version’s constant as long as the oldest supported Django supports that Python. Django chooses code by them where Python changed: django/utils/copy.py is copy.replace from 3.13 and its own backport on 3.12, for the migration operations copied with changes (Operations: the base class and its two halves), and PY314 and PY315 choose how django/utils/inspect.py asks for a signature (Asking a callable what it accepts: django/utils/inspect.py).