pypi django-tenants 3.14.0
v3.14.0

3 hours ago

Breaking

Django 4.2 support dropped (#1248). Django 4.2 LTS reached end of extended support on 7 April 2026 and is now listed upstream under "Unsupported previous releases" — it receives no further security or bug fixes. The minimum is now Django 5.2 LTS, which is supported until April 2028.

django>=5.2,<6.2

The pin is enforced, not merely declared — resolving against 4.2 fails rather than quietly working. If you are still on 4.2, stay on 3.13.0 until you can move to 5.2.

Four version shims went with it: the ImportError fallback around is_psycopg3 (the psycopg_any module has existed since 4.2, so that branch was already unreachable), the < (5, 2) fork over _pre_setup, the all / find_all keyword choice in the finder tests, and the DEFAULT_FILE_STORAGE fallback documented in docs/files.rst for "django < 4.2".

New

Django 6.1 support (#1247, closes #1246). django-tenants did not merely fail tests on 6.1 — it failed to import at all, so manage.py could not start:

ImportError: cannot import name 'DatabaseIntrospection' from partially initialized
module 'django.db.backends.postgresql.introspection'

Django 6.1 added from django.db.backends.postgresql.base import psycopg_version to its PostgreSQL introspection module. base already imports introspection, so the two now form a cycle. Django itself is unaffected because base is always imported first in normal use; the cycle only breaks for a third party importing introspection first, which we did. Fixed by importing the backend's base module first — pure ordering, no behaviour change on any version.

The staticfiles finder tests also used find(path, all=True). That keyword was renamed to find_all in 5.2 and the old spelling removed in 6.1 — note there is no find_all() method, only the renamed keyword. TenantFileSystemFinder does not override find(), so this was test-only.

CI now covers ==6.1.*, installing Django with --pre so a series without a final release yet resolves to its release candidate. That turned out to matter: pip 26.2.1 resolved Django==6.1.* to 6.1rc1 unaided, while pip 25.0.1 reported No matching distribution found — and setup-python bundles whatever pip ships with each interpreter.

Fixes

search_path handling is now driver-agnostic, and psycopg2 is tested (#1249, fixes #1244). _cursor() special-cased psycopg3, never reusing the caller's cursor for SET search_path:

if name or is_psycopg3:
    cursor_for_search_path = self.connection.cursor()   # raw driver cursor
else:
    cursor_for_search_path = cursor                     # Django CursorWrapper

Because self.connection.cursor() returns a raw driver cursor, the statement bypassed Django's cursor wrapper — so on psycopg3 it was invisible to assertNumQueries, while on psycopg2 it was counted. Two tests therefore failed on psycopg2 and had never passed there:

AssertionError: 6 != 3 : 6 queries executed, 3 expected

The special case existed to avoid a recursion. A _setting_search_path flag now makes that re-entry a no-op instead, so the caller's cursor can be reused on both drivers and the SET logic — moved into _handle_search_path() — branches only on whether a cursor was supplied, never on the driver.

This changes no database traffic. With log_statement='all', 3.13.0 and this release both send exactly 646 SET search_path statements for the same test; only their visibility to the wrapper differs. The updated assertion counts are therefore more accurate, not looser — they count statements that were always executed.

CI gains a psycopg-version axis, so the psycopg2 fallback is now exercised on every run rather than being supported in code and untested in practice.

Cached search_path is cleared on rollback() (#1249). A session-level SET is transactional in PostgreSQL — aborting the transaction that issued it reverts the search_path. With TENANT_LIMIT_SET_CALLS enabled, search_path_set_schemas could outlive a value the database had already discarded.

Every search path component is validated (#1249). _get_cursor_search_paths() checked only self.schema_name and then extended the list with PG_EXTRA_SEARCH_PATHS unvalidated, despite all parts being interpolated into the SET statement. All of them now go through _check_schema_name.

Also

  • Tenant-isolation tests for QuerySet.iterator() (#1245). The named-cursor branch of _cursor() had no coverage, and it is the one place in the backend where a mistake reads as working code: a named cursor resolves its table names when Postgres runs DECLARE, so a search_path that had not been set yet would return another tenant's rows rather than raising. The new tests assert against pg_cursors — Postgres' own view of open cursors — so they cannot pass by silently falling back to a client-side fetch. Verified by mutation: breaking the SET search_path makes them fail with tenant 2's rows where tenant 1's were expected.

    Worth recording, since it comes up regularly: DISABLE_SERVER_SIDE_CURSORS is not a django-tenants requirement. It appears nowhere in the library or its docs, and .iterator() is tenant-safe without it. Both configurations are now pinned by tests, so choosing between them stays a connection-pooler question rather than a tenancy one.

  • The "How it works" links in the README and docs index now point somewhere (#1243).

Note for anyone behind PgBouncer

.iterator() and transaction pooling interact in a way that is easy to get backwards. Server-side cursors cannot survive transaction pooling, so a pooler in that mode requires DISABLE_SERVER_SIDE_CURSORS = True — at which point .iterator() fetches the entire result set client-side and no longer bounds memory. A pooler is therefore an obstacle to .iterator(), never a prerequisite for it.

Separately, django-tenants still issues session-level SET search_path, which leaks between tenants under transaction pooling. Session pooling is safe; transaction pooling is not yet supported. #1112 tracks it.

Full Changelog: v3.13.0...v3.14.0

Don't miss a new django-tenants release

NewReleases is sending notifications on new releases.