PostgreSQL & Database Backends
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14 and PostgreSQL 15 or newer, with psycopg 3.3.6, all checked on 2026-10-03; where the 5.2 LTS (5.2.17)
behaves differently the text says so. No PostgreSQL server was available while writing, so nothing on this page was
run against one: the PostgreSQL examples were validated by manage.py check, by makemigrations, by sqlmigrate and
by compiling the queries to SQL with the real PostgreSQL backend and its field, index, constraint and function classes,
with the server round-trips stubbed out, and the portable SQLite code was executed and tested. The section
What was run, and what was not lists, example by example, what was executed and what was not. Choosing PostgreSQL is the most common
decision a Django project makes about its database, and the reason is not only quality: django.contrib.postgres
exposes PostgreSQL’s arrays, ranges, hstore, exclusion constraints, GIN/GiST indexes and full-text search as ordinary
fields, lookups and expressions. In the Bookshelf application that grows across this section, PostgreSQL stores the
catalogue, tags books with arrays, prevents two book-club meetings from overlapping in a room with an exclusion
constraint and powers the catalogue search with a stored, weighted full-text vector. This page covers the backend notes
for every officially supported database, then django.contrib.postgres (fields, indexes, constraints, migration
operations, aggregates), then search (full-text and trigram), and ends with pitfalls, what is outdated in older books
and what changed recently.
In a hurry: what goes where
| Need | Use | Section |
|---|---|---|
Which database and driver |
PostgreSQL 15+ with psycopg 3 ( |
|
Connection reuse |
|
|
A list of tags on a row |
|
|
Free-form string key/values |
|
|
A period of time or a numeric interval |
The five range fields; |
|
"No two active rows may overlap" |
|
|
Add an index to a live table |
|
Migration operations: extensions, collations, concurrent indexes |
Add a check constraint to a big table |
|
|
Search text |
A stored |
|
Typo-tolerant name search |
|
|
Same code on SQLite in tests |
Branch on |
What was run, and what was not
Every claim about behaviour comes from the Django 6.1 documentation; the examples were exercised in a scratch project with Django 6.1.1, Python 3.13, psycopg 3.3.6 and psycopg-pool 3.3.3. The honest summary:
| Example | Needs a live PostgreSQL | What was done |
|---|---|---|
|
yes |
Loaded and passed |
django-environ |
no |
Executed: the parsed dictionary, the import and the subclass ( |
SQLite |
no |
Executed on SQLite 3.53: |
|
the PostgreSQL branch |
The SQLite branch and an async variant ran in a Django |
Models with |
yes, to create the tables |
|
Migrations with extensions, |
yes, to apply |
|
Lookups, search, trigram and aggregate queries |
yes, to return rows |
Compiled with |
Constraint-violation and search tests |
yes |
Written but not executed; they are marked "needs PostgreSQL". |
MySQL, MariaDB and Oracle |
yes (and a driver) |
Copied from the documentation; not executed (no |
The stubs live only in the scratch project: they replace DatabaseWrapper.get_new_connection with a mock,
fix pg_version, and substitute the query parameters textually, which is why quoted SQL below shows parameters
unquoted when str(queryset.query) was used (Django documents that this string is not valid SQL). To reproduce the
real results, start PostgreSQL 15+ (for example the official container, see
Settings & Configuration) and run the tests in
Testing on PostgreSQL with DJANGO_SETTINGS_MODULE pointing at PostgreSQL settings.
The supported backends
Django officially supports PostgreSQL, MariaDB, MySQL, Oracle and SQLite, and documents a list of third-party backends. It tries to offer the same ORM everywhere, but "not all database backends are alike": the notes below are the differences that most often matter. They are not a replacement for the database’s own manual.
| Backend | Minimum version (Django 6.1) | Driver | ENGINE |
|---|---|---|---|
PostgreSQL |
15 (14 dropped in 6.1; 5.2 LTS needs 14) |
psycopg 3.1.12+ (recommended) or psycopg2 2.9.9+; support for psycopg2 is likely to be deprecated and removed |
|
MariaDB |
10.11 |
the MySQL driver (shared backend) |
|
MySQL |
8.4 |
|
|
Oracle |
19c |
|
|
SQLite |
3.37.0 |
the standard-library |
|
Source: Django: Databases
Django assumes UTF-8 everywhere; another encoding produces confusing "value too long" errors for valid data. Third-party backends listed in the documentation include CockroachDB, Firebird, Google Cloud Spanner, Microsoft SQL Server, MongoDB, Snowflake, TiDB and YugabyteDB; their supported Django versions and ORM features "vary considerably", so questions go to their own projects. The database-agnostic parts of this section are covered in Models & Fields and Making Queries; the SQL itself (joins, window functions, transactions) is in SQL.
Connections: lifetime, health checks and pools
Django opens a connection the first time a request needs one and, by default, closes it at the end of the request
(CONN_MAX_AGE = 0). settings.py configures this per alias; the full table of DATABASES keys is in
Settings & Configuration. What matters for the backend choice:
-
CONN_MAX_AGEset to a positive number of seconds (orNonefor unlimited) keeps one connection per thread alive between requests. Django closes an over-age or broken connection at the start or end of a request. Your database must allow at least as many connections as you have worker threads. The development server creates a thread per request, so persistence does nothing there. -
CONN_HEALTH_CHECKS = Truechecks a reused connection once per request, and only if the request touches the database. It prevents errors after the server closed an idle connection or restarted. -
Under ASGI, persistent connections should be disabled. The async ORM methods run the queries in a thread, so a long lifetime per thread does not help; use the backend’s pool (or an external pooler) and size it to the number of queries you want in flight.
-
PostgreSQL pools (5.1, psycopg only): set
"pool"inOPTIONStoTrue(theConnectionPooldefaults) or to a dictionary ofConnectionPooloptions. This needspsycopg[pool], andCONN_MAX_AGEmust be0; connection lifetime is themax_lifetimepool option. Django keeps one pool per database alias per process, and closing a Django connection returns it to the pool. Unless you pass acheckcallable,CONN_HEALTH_CHECKSdecides whether connections are checked on checkout. -
Oracle pools (5.2):
"pool": Trueor a dictionary for the driver’screate_pool().
# config/settings_pg.py -- needs a PostgreSQL server and psycopg[pool]
import os
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("POSTGRES_DB", "bookshelf"),
"USER": os.environ.get("POSTGRES_USER", "bookshelf"),
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""), # from the secret store
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
"PORT": os.environ.get("POSTGRES_PORT", "5432"),
"CONN_MAX_AGE": 0, # must be 0 when "pool" is enabled
"CONN_HEALTH_CHECKS": True,
"OPTIONS": {
"pool": {"min_size": 2, "max_size": 10, "timeout": 10},
},
}
}
Executed here only as far as check (with the stubs described in What was run, and what was not). A pool that is configured but cannot
reach the server fails on the first query with OperationalError: couldn’t get a connection after 10.00 sec; leaving
out psycopg-pool fails with ImproperlyConfigured: Error loading psycopg_pool module. Did you install
psycopg[pool]?. The slow-query side of connections (pool sizing, Debug Toolbar) continues in
Query Optimization & Fetch Modes and
Diagnosing Slow Queries.
When a connection is created outside the request cycle (a script, a thread you start, a management command that runs
for hours) nothing closes it for you: call django.db.close_old_connections() or close it explicitly.
A URL-style configuration
Many deployments inject a single DATABASE_URL. django-environ turns it into the dictionary Django expects; the options
in the query string become OPTIONS:
import environ
env = environ.Env()
cfg = env.db_url_config("postgres://bookshelf:change-me@db.internal:5432/bookshelf?sslmode=require")
print({k: v for k, v in cfg.items() if k != "PASSWORD"})
{'NAME': 'bookshelf', 'USER': 'bookshelf', 'HOST': 'db.internal', 'PORT': 5432, 'OPTIONS': {'sslmode': 'require'}, 'ENGINE': 'django.db.backends.postgresql'}
This one was executed (django-environ 0.14.0). Read the real value from the environment in production with
env.db("DATABASE_URL"); the literal password above is a placeholder. Note that a pool setting is not expressible in
the URL and must be added to the dictionary afterwards.
PostgreSQL notes
The PostgreSQL backend passes the contents of OPTIONS to the connection constructor as keyword arguments, so any libpq
parameter works (sslmode, connect_timeout, application_name, and so on). The options Django documents itself:
| Option | Effect | Since |
|---|---|---|
|
Connect through an entry of the connection service file ( |
4.0 |
|
Defaults to |
long-standing |
|
Run the connection under a different role than the one used to log in. |
4.2 |
|
psycopg 3.1.8+ binds parameters on the client by default; |
4.2 |
|
Connection pooling, see Connections: lifetime, health checks and pools. |
5.1 |
Source: Django: PostgreSQL notes
from django.db.backends.postgresql.psycopg_any import IsolationLevel
DATABASES["default"]["OPTIONS"] |= {
"isolation_level": IsolationLevel.SERIALIZABLE,
"assume_role": "bookshelf_app",
"server_side_binding": True,
}
The import was executed (it printed 4 for IsolationLevel.SERIALIZABLE); the options were not applied to a server.
Further PostgreSQL facts from the notes:
-
Fewer round-trips per connection. Django needs
client_encodingset toUTF8, the isolation level (read committedby default) andtimezone(UTC whenUSE_TZisTrue). If the role already has those values, Django skips theSETstatements for each new connection; set them withALTER ROLEor inpostgresql.conf. -
varcharandtextget two indexes withdb_index=True. Django adds a second index with thelikeoperator class (varchar_pattern_ops/text_pattern_ops), needed forLIKE-based lookups such ascontainsandstartswith. Thesqlmigrateoutput of aunique=TrueCharFieldshows it (pgdemo_room_name…_like). -
Server-side cursors and poolers.
QuerySet.iterator()opens a server-side cursor, and PostgreSQL assumes only the first 10% of a cursor query will be fetched (cursor_tuple_fraction). Behind a transaction-pooling pooler such as PgBouncer, setDISABLE_SERVER_SIDE_CURSORS = Truefor that alias, or wrap the iteration inatomic(). -
Identity columns and explicit primary keys. Auto-incrementing keys are identity columns fed by a sequence. Creating a row with an explicit
pkdoes not advance the sequence, so a later insert can raiseIntegrityError: duplicate key value violates unique constraint. After loading data with explicit keys, runmanage.py sqlsequencereset <app>and execute the output. -
Extensions such as
hstore,pg_trgmorbtree_gistare enabled through migration operations, see Migration operations: extensions, collations, concurrent indexes. -
Tests.
TEST["TEMPLATE"]names the template database the test database is created from; the notes also describe speeding the test run with non-durable settings on a throwaway server, which is dangerous on anything you care about.
MySQL and MariaDB notes
MariaDB uses the MySQL backend (ENGINE = "django.db.backends.mysql"), so the notes apply to both. They were not
executed here; they are the documentation’s, grouped by what goes wrong:
| Topic | What to know |
|---|---|
Storage engine |
Use InnoDB (transactions, foreign keys). MyISAM does not enforce foreign keys and does not support transactions; savepoint methods raise database errors on it. Django does not choose an engine when creating tables, so set the server default. |
Character set |
Create the database with |
Collation |
The default |
|
Keep strict mode ( |
Isolation level |
Django works best with, and defaults to, |
Time zones and fractions |
Load the time zone tables with |
Row locking |
|
Type coercion |
Querying a string column with an integer makes MySQL coerce every row to an integer (so |
Table names |
MySQL can alter the case of table names under some conditions; keep |
Source: Django: MySQL notes
# Not executed: needs mysqlclient and a MySQL 8.4+ or MariaDB 10.11+ server
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": "bookshelf",
"USER": "bookshelf",
"PASSWORD": os.environ["MYSQL_PASSWORD"],
"HOST": "127.0.0.1",
"OPTIONS": {
"charset": "utf8mb4",
"isolation_level": "read committed",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
The charset option name is mysqlclient’s; the documentation’s own sample uses a read_default_file option file with
default-character-set = utf8mb4. Connection settings are applied in this order: OPTIONS, then NAME, USER,
PASSWORD, HOST, PORT, then the option file.
SQLite notes
SQLite is the zero-configuration backend (the standard-library driver, 3.37.0 or newer) and the one every example in this section can fall back to. The differences that surprise people:
-
Substring and case matching.
containsandiexactmatch case-insensitively for substrings, but exact matches on strings with non-ASCII characters are case-sensitive even withiexact. -
Decimals are floats.
DecimalFieldis stored asREAL, so decimal arithmetic is not exact. -
"database is locked". Too much write concurrency. Shorten transactions, raise
OPTIONS["timeout"](which only waits longer) or move to another backend. -
Transaction mode.
OPTIONS["transaction_mode"]acceptsDEFERRED(default),IMMEDIATEandEXCLUSIVE(5.1);IMMEDIATEwaits for the lock up to the timeout.ATOMIC_REQUESTSis discouraged with it. -
Pragmas.
OPTIONS["init_command"]runs statements at connection time (5.1). -
No row locking.
select_for_update()is accepted but has no effect. -
Mutating a table while iterating. Rows changed inside an
iterator()loop may appear zero or two times. -
JSON.
JSONFieldneeds the JSON1 extension, enabled by default from SQLite 3.38; otherwisefields.E180.
# config/settings_lite.py
from .settings import * # noqa: F403
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "lite.sqlite3", # noqa: F405
"OPTIONS": {
"init_command": "PRAGMA synchronous=3; PRAGMA cache_size=2000;",
"transaction_mode": "IMMEDIATE",
"timeout": 20,
},
}
}
$ python manage.py shell --settings=config.settings_lite -c "
from django.db import connection
c = connection.cursor()
print(c.execute('PRAGMA synchronous').fetchone(), c.execute('PRAGMA cache_size').fetchone())
print(connection.settings_dict['OPTIONS'], connection.vendor)"
(3,) (2000,)
{'init_command': 'PRAGMA synchronous=3; PRAGMA cache_size=2000;', 'transaction_mode': 'IMMEDIATE', 'timeout': 20} sqlite
Source: Django: SQLite notes
This example was executed. A longer treatment of the same options, in the context of the whole DATABASES setting, is in
Settings & Configuration.
Oracle notes
Oracle 19c or newer with oracledb 2.3.0 or newer. Nothing here was executed (no server, no driver). The points that
shape a project:
-
Privileges.
migrateneedsCREATE TABLE,CREATE SEQUENCE,CREATE PROCEDUREandCREATE TRIGGER. Running a test suite needs additional user and tablespace privileges (CREATE USER,ALTER USER,DROP USER,CREATE TABLESPACE,DROP TABLESPACEand theWITH ADMIN OPTIONvariants), all included in theDBArole, which is appropriate on a private developer database only. The backend also usesSYS.DBMS_LOBandSYS.DBMS_RANDOM. -
Connecting. With a service name (or a
tnsnames.oraentry) leaveHOSTandPORTempty; with a SID fill in both. A full DSN or Easy Connect string can go intoNAMEwhenHOSTandPORTare empty ("NAME": "localhost:1521/orclpdb1"). -
Pool (5.2).
"OPTIONS": {"pool": True}, or a dictionary ofcreate_pool()parameters such asminandmax. -
INSERT … RETURNING INTO. The backend uses it to read generated keys; disable with"use_returning_into": FalseinOPTIONSfor unusual setups such as remote tables or views withINSTEAD OFtriggers. -
Names. Identifiers are limited to 30 characters (truncated with an MD5 suffix) and upper-cased; a quoted
db_tablesuch as'"name_left_in_lowercase"'avoids both. Do not name a fielddate,timestamp,numberorfloat. -
NULLand empty strings. Oracle treats them identically, so the backend ignores an explicitnull=Falseon fields that allow''and readsNULLback as''. -
TextFieldis anNCLOB. It cannot be a primary key, be indexed or appear inSELECT DISTINCT; combinedefer()withdistinct()as a workaround.
Source: Django: Oracle notes
Subclassing a backend, and third-party backends
To change one behaviour of a built-in backend (a feature flag, an operation) you subclass it: a package with a base.py
that defines a DatabaseWrapper subclass, then ENGINE set to the package. This is also the pattern the third-party
backends use.
# mydbengine/base.py
from django.db.backends.postgresql import base, features
class DatabaseFeatures(features.DatabaseFeatures):
def allows_group_by_selected_pks_on_model(self, model):
return True
class DatabaseWrapper(base.DatabaseWrapper):
features_class = DatabaseFeatures
DATABASES = {"default": {"ENGINE": "mydbengine", "NAME": "bookshelf"}}
Executed: with that setting Django resolved the engine, connection.features came from mydbengine.base and
connection.vendor was still postgresql. Keep such subclasses small; each Django release can change the feature
flags and operation methods you override.
Writing code that survives two backends
The cleanest policy is to test on the database you deploy to (Testing on PostgreSQL). When a project must run on SQLite as well
(a tutorial, a laptop without Docker), isolate the PostgreSQL-only code behind connection.vendor, which is
"postgresql", "mysql", "sqlite" or "oracle":
# portable/search.py
from django.db import connections
from django.db.models import Q, QuerySet
def search_books(qs: QuerySet, term: str, using: str = "default") -> QuerySet:
"""Full-text search on PostgreSQL, a plain substring match everywhere else."""
if connections[using].vendor == "postgresql":
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector
vector = SearchVector("title", weight="A") + SearchVector("summary", weight="B")
query = SearchQuery(term, search_type="websearch")
return (
qs.annotate(search=vector, rank=SearchRank(vector, query))
.filter(search=query) # the @@ match, not a rank threshold
.order_by("-rank")
)
return qs.filter(Q(title__icontains=term) | Q(summary__icontains=term))
# portable/tests.py
from django.db import connection
from django.test import TestCase
from .models import Book
from .search import search_books
class SearchBooksTests(TestCase):
def test_substring_fallback_on_non_postgresql(self):
Book.objects.create(title="Two Cheeses", summary="")
Book.objects.create(title="Other", summary="all about cheese")
Book.objects.create(title="Unrelated", summary="nothing")
found = search_books(Book.objects.all(), "cheese")
self.assertEqual(connection.vendor, "sqlite")
self.assertEqual(sorted(b.title for b in found), ["Other", "Two Cheeses"])
async def test_async_iteration(self):
await Book.objects.acreate(title="Async Cheese", summary="")
titles = [b.title async for b in search_books(Book.objects.all(), "cheese")]
self.assertEqual(titles, ["Async Cheese"])
$ python manage.py test portable --settings=config.settings_lite
Found 2 test(s).
...
Ran 2 tests in 0.002s
OK
Source: Django: Search
Both tests ran on SQLite. The PostgreSQL branch compiled to the following SQL (only the FROM/WHERE shape matters; the
parameters are unquoted in the str() rendering):
SELECT ..., (setweight(to_tsvector(COALESCE("portable_book"."title", '')), 'A') || setweight(to_tsvector(COALESCE("portable_book"."summary", '')), 'B')) AS "search",
ts_rank((...same vector...), websearch_to_tsquery('django -flask')) AS "rank"
FROM "portable_book"
WHERE (...same vector...) @@ (websearch_to_tsquery('django -flask'))
ORDER BY 5 DESC
Source: Django: PostgreSQL notes
The async test shows that the same expressions work with the async ORM (acreate, async for); transactions are the part
of the async ORM that does not work yet, so wrap transactional code in one synchronous function and call it with
sync_to_async(). Do not try to hide real differences (ranking, stemming, collation) behind such a helper: the fallback
only keeps a development environment usable.
django.contrib.postgres
django.contrib.postgres is a Django app that ships PostgreSQL-specific fields, indexes, constraints, lookups, aggregates,
expressions, validators, form fields and migration operations. Add it to INSTALLED_APPS to use the lookups and
expressions that register themselves (search, trigram_similar, unaccent, OpClass):
INSTALLED_APPS = [
"django.contrib.postgres",
"pgdemo",
# ...
]
Source: Django: django.contrib.postgres
Since Django 6.0, model fields, indexes and constraints from the package carry a system check that fails when the app is not
installed, instead of failing later with a database error. With the app removed from INSTALLED_APPS, manage.py check
printed (excerpt, executed):
pgdemo.Book.extra: (postgres.E005) 'django.contrib.postgres' must be in INSTALLED_APPS in order to use HStoreField.
pgdemo.Book.print_run: (postgres.E005) 'django.contrib.postgres' must be in INSTALLED_APPS in order to use DateRangeField.
pgdemo.Book.search_vector: (fields.E223) GeneratedField.output_field has errors:
'django.contrib.postgres' must be in INSTALLED_APPS in order to use SearchVectorField. (postgres.E005)
pgdemo.Book: (postgres.E005) 'django.contrib.postgres' must be in INSTALLED_APPS in order to use GinIndex.
pgdemo.Booking: (postgres.E005) 'django.contrib.postgres' must be in INSTALLED_APPS in order to use ExclusionConstraint.
System check identified 10 issues (0 silenced).
Source: Django: django.contrib.postgres
The rest of this section builds one scratch app (pgdemo) around the Bookshelf catalogue. Its models use every kind of
field, index and constraint that follows, so the complete file comes first and the sections after it explain each part:
# pgdemo/models.py
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import (
ArrayField,
DateRangeField,
DateTimeRangeField,
HStoreField,
RangeOperators,
)
from django.contrib.postgres.indexes import BTreeIndex, GinIndex, OpClass
from django.contrib.postgres.search import SearchVector, SearchVectorField
from django.db import models
from django.db.models import Q
from django.db.models.functions import Upper
class Author(models.Model):
name = models.CharField(max_length=200)
class Meta:
indexes = [
# Trigram index: speeds up trigram_similar / TrigramSimilarity
GinIndex(
OpClass(Upper("name"), name="gin_trgm_ops"),
name="author_name_trgm_idx",
),
]
def __str__(self) -> str:
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
summary = models.TextField(blank=True)
# default must be a callable: a shared mutable list would leak between rows
tags = ArrayField(models.CharField(max_length=40), default=list, blank=True)
# keys and values are strings (or None); use JSONField for nested data
extra = HStoreField(default=dict, blank=True)
print_run = DateRangeField(null=True, blank=True)
pages = models.PositiveIntegerField(null=True, blank=True)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
# PostgreSQL maintains this column itself (GeneratedField, Django 5.0)
search_vector = models.GeneratedField(
expression=SearchVector("title", weight="A", config="english")
+ SearchVector("summary", weight="B", config="english"),
output_field=SearchVectorField(),
db_persist=True,
)
class Meta:
indexes = [
GinIndex(fields=["search_vector"], name="book_search_vector_idx"),
GinIndex(fields=["tags"], name="book_tags_gin_idx"),
BTreeIndex(fields=["title"], fillfactor=80, name="book_title_idx"),
]
constraints = [
models.CheckConstraint(condition=Q(pages__gt=0), name="book_pages_positive"),
]
def __str__(self) -> str:
return self.title
class Room(models.Model):
name = models.CharField(max_length=80, unique=True)
class Booking(models.Model):
"""A book-club meeting: no two active bookings may overlap in the same room."""
room = models.ForeignKey(Room, on_delete=models.CASCADE)
timespan = DateTimeRangeField()
cancelled = models.BooleanField(default=False)
class Meta:
constraints = [
ExclusionConstraint(
name="exclude_overlapping_bookings",
expressions=[
("timespan", RangeOperators.OVERLAPS),
("room", RangeOperators.EQUAL),
],
condition=Q(cancelled=False),
),
]
$ python manage.py check --settings=config.settings_pg_nodb
System check identified no issues (0 silenced).
$ python manage.py makemigrations pgdemo --settings=config.settings_pg_nodb
Migrations for 'pgdemo':
pgdemo/migrations/0001_initial.py
+ Create model Room
+ Create model Author
+ Create model Book
+ Create model Booking
$ python manage.py makemigrations --check --dry-run --settings=config.settings_pg_nodb
No changes detected
config.settings_pg_nodb is the scratch settings module that stubs the server round-trips (see What was run, and what was not); with a real
database the commands are the same without --settings. The GeneratedField and the two GinIndex entries on
Book are explained in Full-text search; ExclusionConstraint in ExclusionConstraint.
Migration operations: extensions, collations, concurrent indexes
django.contrib.postgres.operations provides the operations that make migrations PostgreSQL-aware. Their position in
the request cycle is "deployment": they run during migrate, never while serving a request.
Extensions
CreateExtension(name, hints=None) installs a PostgreSQL extension, skipping it if it already exists; subclasses exist for
the common ones: BloomExtension, BtreeGinExtension, BtreeGistExtension, CITextExtension, CryptoExtension
(pgcrypto), HStoreExtension, TrigramExtension (pg_trgm) and UnaccentExtension. Put the operation before the first
operation that uses it: for HStoreField that is the first CreateModel or AddField involving it, for gin_trgm_ops
the first index with it, for an exclusion constraint on a scalar column btree_gist. HStoreExtension also sets up the
connection to interpret hstore values for later operations in the same migration. The hints argument (6.0) is passed to
database routers' allow_migrate(), so routed projects can send extension creation to the right database.
# pgdemo/migrations/0001_initial.py (excerpt: operations list; the rest is generated)
from django.contrib.postgres.operations import (
BtreeGistExtension,
HStoreExtension,
TrigramExtension,
)
operations = [
# extensions first: later operations use hstore, gin_trgm_ops and btree_gist
HStoreExtension(),
TrigramExtension(),
BtreeGistExtension(),
# migrations.CreateModel(...), generated by makemigrations
]
Most extensions need a database user with superuser privileges. If the application’s user does not have it, a
privileged user creates the extension once (CREATE EXTENSION IF NOT EXISTS hstore; in the application database) and the
operation then finds it. In tests, Django creates the test database by running the migrations, so the extension
operations run there too, and the test user needs the same privilege.
sqlmigrate for the whole migration (executed with the stubs; the three extension steps print -- (no-op) there only
because the stub pretends they exist):
CREATE TABLE "pgdemo_book" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"title" varchar(200) NOT NULL, "summary" text NOT NULL, "tags" varchar(40)[] NOT NULL,
"extra" hstore NOT NULL, "print_run" daterange NULL,
"search_vector" tsvector GENERATED ALWAYS AS ((setweight(to_tsvector('english'::regconfig, COALESCE("title", '')), 'A')
|| setweight(to_tsvector('english'::regconfig, COALESCE("summary", '')), 'B'))) STORED,
"author_id" bigint NOT NULL);
CREATE TABLE "pgdemo_booking" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
"timespan" tstzrange NOT NULL, "cancelled" boolean NOT NULL, "room_id" bigint NOT NULL,
CONSTRAINT "exclude_overlapping_bookings" EXCLUDE USING GIST ("timespan" WITH &&, "room_id" WITH =) WHERE (NOT "cancelled"));
CREATE INDEX "author_name_trgm_idx" ON "pgdemo_author" USING gin ((UPPER("name")) gin_trgm_ops);
CREATE INDEX "book_search_vector_idx" ON "pgdemo_book" USING gin ("search_vector");
CREATE INDEX "book_tags_gin_idx" ON "pgdemo_book" USING gin ("tags");
Source: Django: sqlmigrate
(Line breaks added for reading; the pages column and the two later indexes arrive in later migrations, shown below.)
Collations
CreateCollation(name, locale, *, provider='libc', deterministic=True) and RemoveCollation manage collations that your
operating system or ICU provides but PostgreSQL does not yet list. A collation created with deterministic=False and the
icu provider is how you get case-insensitive filtering and ordering on a column through db_collation:
from django.contrib.postgres.operations import CreateCollation
operations = [
CreateCollation(
"case_insensitive",
provider="icu",
locale="und-u-ks-level2",
deterministic=False,
),
]
Not executed. This replaces the CICharField, CIEmailField and CITextField of older Django versions and the citext
extension: they were deprecated in 4.2 and removed in 5.1. A non-deterministic collation cannot be used with LIKE-style
lookups on every PostgreSQL version, so test the queries you need.
Adding an index to a live table
A plain CREATE INDEX blocks writes to the table while it builds. AddIndexConcurrently(model_name, index) and
RemoveIndexConcurrently(model_name, name) use PostgreSQL’s CONCURRENTLY option, which does not. That option cannot
run inside a transaction, so the migration must set atomic = False:
# pgdemo/migrations/0003_book_title_idx_concurrently.py
from django.contrib.postgres.indexes import BTreeIndex
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations
class Migration(migrations.Migration):
# CREATE INDEX CONCURRENTLY cannot run inside a transaction block
atomic = False
dependencies = [("pgdemo", "0002_book_pages")]
operations = [
AddIndexConcurrently(
"book",
BTreeIndex(fields=["title"], fillfactor=80, name="book_title_idx"),
),
]
$ python manage.py sqlmigrate pgdemo 0003 --settings=config.settings_pg_nodb
--
-- Concurrently create index book_title_idx on field(s) title of model book
--
CREATE INDEX CONCURRENTLY "book_title_idx" ON "pgdemo_book" USING btree ("title") WITH (fillfactor = 80);
sqlmigrate output executed; the statement was not run. Note that the output has no BEGIN/COMMIT, unlike the atomic
migrations, and that a concurrent build that fails leaves an invalid index that you must drop before retrying. Keep a
non-atomic migration to this one operation so a partial failure leaves a known state. The general migration workflow,
including squashing and zero-downtime schema changes, is in Migrations and
Constraints, Indexes & Composite Keys.
Adding a constraint without scanning the table
AddConstraintNotValid(model_name, constraint) (4.0) adds a check constraint with NOT VALID: new and updated rows are
checked, existing rows are not scanned. ValidateConstraint(model_name, name) later scans the table. The documentation
requires them to be in two separate migrations: in one atomic migration they are equivalent to a plain AddConstraint, and
in one non-atomic migration a failure of the second leaves the database inconsistent.
# pgdemo/migrations/0004_book_pages_positive_not_valid.py
from django.contrib.postgres.operations import AddConstraintNotValid
from django.db import migrations, models
from django.db.models import Q
class Migration(migrations.Migration):
dependencies = [("pgdemo", "0003_book_title_idx_concurrently")]
operations = [
# ADD CONSTRAINT ... NOT VALID: new writes are checked, old rows are not scanned yet
AddConstraintNotValid(
"book",
models.CheckConstraint(condition=Q(pages__gt=0), name="book_pages_positive"),
),
]
# pgdemo/migrations/0005_validate_book_pages_positive.py
from django.contrib.postgres.operations import ValidateConstraint
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [("pgdemo", "0004_book_pages_positive_not_valid")]
operations = [ValidateConstraint("book", name="book_pages_positive")]
-- 0004
ALTER TABLE "pgdemo_book" ADD CONSTRAINT "book_pages_positive" CHECK ("pages" > 0) NOT VALID;
-- 0005
ALTER TABLE "pgdemo_book" VALIDATE CONSTRAINT "book_pages_positive";
Executed through sqlmigrate (the stub renders the literal as Int4(0); the real SQL has 0). With the models in
django.contrib.postgres declaring the same CheckConstraint, makemigrations --check reported no changes, which is how you
know the hand-written pair matches the model state.
ArrayField
ArrayField(base_field, size=None, **options) stores a list. The base field is any field instance except relations and
file fields, and arrays can be nested (they must then be rectangular). size is passed to the database but PostgreSQL
does not enforce it. Transformation, validation and serialisation are delegated to the base field. The default must be a
callable: default=[] is shared between all instances, and the system check says so. Executed against the field in
pgdemo/models.py and a variant with default=[]:
baddefault.Post.tags: (fields.E010) ArrayField default should be a callable instead of an instance so that it's not shared between all field instances.
HINT: Use a callable instead, e.g., use `list` instead of `[]`.
Source: Django: ArrayField
| Lookup or transform | Meaning | SQL operator |
|---|---|---|
|
All given values are in the array (subset) |
|
|
Every array value is among the given values |
|
|
Any value in common (a queryset of |
|
|
Array length; further lookups are |
|
|
Element by position, zero-based like Python; later lookups are the base field’s |
|
|
Sub-array; later lookups unchanged |
|
Index transforms never raise for an index past the end; they simply do not match. In raw SQL PostgreSQL arrays are one-based, in Django’s lookups they are zero-based. Slices and indexes on multidimensional arrays behave oddly at the database level; only index all the way down to the final element.
Book.objects.filter(tags__contains=["django"]) # has this tag
Book.objects.filter(tags__overlap=["django", "python"]) # has either tag
Book.objects.filter(tags__len=2)
Book.objects.filter(tags__0="django") # first tag
Book.objects.filter(tags__0_2__contains=["django"]) # among the first two
WHERE "pgdemo_book"."tags" @> (ARRAY['django'])::varchar(40)[]
WHERE "pgdemo_book"."tags" && (ARRAY['django', 'python'])::varchar(40)[]
WHERE CASE WHEN "pgdemo_book"."tags" IS NULL THEN NULL ELSE coalesce(array_length("pgdemo_book"."tags", 1), 0) END = 2
WHERE ("pgdemo_book"."tags")[1] = 'django'
WHERE ("pgdemo_book"."tags")[1:2] @> (ARRAY['django'])::varchar(40)[]
Source: Django: Querying ArrayField
Compiled, not run against rows (the rendering shows parameters unquoted; the SQL above is that output with quotes added).
Array columns want a GIN index (GinIndex(fields=["tags"]) in the model), which is what makes @> and && fast; a plain
db_index=True creates a B-tree, which does not help with those operators. A many-to-many relation is still the right
model for tags that have their own attributes, are shared across rows or need referential integrity; an array is for
simple, self-contained lists that are read and written together with the row.
HStoreField
HStoreField stores a dictionary of string keys and string-or-None values. It needs django.contrib.postgres in
INSTALLED_APPS and the hstore extension (HStoreExtension()); missing the first gives can’t adapt type 'dict',
missing the second gives type "hstore" does not exist. Use models.JSONField (3.1, portable) for nested or typed
data; hstore remains a good fit for flat string attributes with GiST or GIN indexes.
Any lookup name that is not one of the documented lookups is interpreted as a key lookup, with no error, so a typo in a
lookup silently queries a key that does not exist. Check queries return what you intend. When a key clashes with a lookup
name (such as contains), use the field’s contains lookup explicitly.
Book.objects.filter(extra__has_key="isbn") # key present
Book.objects.filter(extra__isbn="978-0-262-03384-8") # key lookup (value equals)
Book.objects.filter(extra__contains={"format": "hardcover"}) # all pairs present
Book.objects.filter(extra__has_any_keys=["isbn", "doi"])
WHERE "pgdemo_book"."extra" ? 'isbn'
WHERE ("pgdemo_book"."extra" -> 'isbn') = '978-0-262-03384-8'
WHERE "pgdemo_book"."extra" @> '{"format": "hardcover"}'
Source: Django: HStoreField
Compiled only. The other documented lookups are contained_by, has_keys, and the keys/values transforms (arrays you
combine with ArrayField lookups). inspectdb introspects HStoreField since 6.1, when psycopg 3.2+ is installed and
django.contrib.postgres is installed. To require or restrict keys, use KeysValidator(keys, strict=False, messages=None)
from django.contrib.postgres.validators.
Range fields
PostgreSQL range types store an interval as one value, and Django maps them to five fields. In Python they are psycopg
Range objects (import the classes from django.db.backends.postgresql.psycopg_any, which works with psycopg 2 and 3),
and the fields also accept tuples.
| Field | PostgreSQL type | Python class |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Bounds default to lower-inclusive, upper-exclusive ([)). The default_bounds argument (4.1) changes this for list and
tuple input on the two non-discrete fields; discrete types (integers, dates) are always returned in the canonical [)
form. A range with equal inclusive-lower and exclusive-upper values has no points; PostgreSQL normalises it to empty
and the original bound values are lost.
import datetime
from django.db.backends.postgresql.psycopg_any import DateRange
jan_2026 = DateRange(datetime.date(2026, 1, 1), datetime.date(2026, 2, 1))
Book.objects.filter(print_run__contains=datetime.date(2026, 1, 15)) # range contains a point
Book.objects.filter(print_run__overlap=jan_2026) # ranges overlap
Book.objects.filter(print_run__startswith=datetime.date(2026, 1, 1)) # lower bound equals
WHERE "pgdemo_book"."print_run" @> '2026-01-15'::date
WHERE "pgdemo_book"."print_run" && '[2026-01-01, 2026-02-01)'
WHERE lower("pgdemo_book"."print_run") = '2026-01-01'
Source: Django: Range fields
Compiled only. Other lookups and transforms: contained_by (also available on plain numeric, date and datetime fields),
fully_lt, fully_gt, not_lt, not_gt, adjacent_to, isempty, lower_inc, lower_inf, upper_inc, upper_inf,
endswith, and RangeOperators/RangeBoundary for expressions and constraints. RangeMinValueValidator and
RangeMaxValueValidator bound a range in forms and full_clean(). For a GiST index on a range column use
GistIndex(fields=["print_run"]) (see the next section).
PostgreSQL index types
Meta.indexes is the same mechanism as in any Django model (see
Constraints, Indexes & Composite Keys); the classes in
django.contrib.postgres.indexes choose the PostgreSQL access method. Index and db_index=True create a B-tree, which
is not helpful for arrays, ranges, hstore or search vectors.
| Class | Parameters | Typical use |
|---|---|---|
|
|
The default; scalar columns with a tuned fill factor |
|
|
Arrays, |
|
|
Range fields, |
|
|
Space-partitioned data |
|
|
Very large, naturally ordered tables (timestamps in an append-only log) |
|
|
Equality lookups only |
|
|
Equality on many columns |
All accept the generic Index options (name, condition for partial indexes, include for covering indexes,
expressions for functional indexes). OpClass(expression, name) attaches an operator class, which is how the trigram index
in the models is written (gin_trgm_ops); it also works in functional unique constraints and exclusion constraints.
from django.contrib.postgres.indexes import GinIndex, OpClass
from django.contrib.postgres.search import SearchVector
from django.db.models.functions import Upper
# in Meta.indexes
GinIndex(OpClass(Upper("name"), name="gin_trgm_ops"), name="author_name_trgm_idx")
GinIndex(SearchVector("title", "summary", config="english"), name="book_fts_idx")
CREATE INDEX "author_name_trgm_idx" ON "pgdemo_author" USING gin ((UPPER("name")) gin_trgm_ops);
CREATE INDEX "book_fts_idx" ON "pgdemo_book" USING gin ((to_tsvector('english'::regconfig, COALESCE("title", '') || ' ' || COALESCE("summary", ''))));
Source: Django: OpClass() expressions
The two statements were produced by Index.create_sql() and sqlmigrate and not executed. An index only helps if the
query uses the same expression: the functional index on UPPER(name) serves a query that compares Upper("name"), not
one that compares name directly. Confirm with QuerySet.explain() on the real data.
ExclusionConstraint
An exclusion constraint generalises a unique constraint to any commutative operator: "no two rows may satisfy this operator on these expressions at the same time". PostgreSQL implements it with an index (GiST by default). The classic use is preventing overlapping bookings, and it is the only reliable way to do that: checking overlap in Python before saving has a race between two concurrent requests.
ExclusionConstraint(*, name, expressions, index_type=None, condition=None, deferrable=None, include=None,
violation_error_code=None, violation_error_message=None):
-
expressions: an iterable of(expression_or_field_name, operator)pairs; useRangeOperatorsinstead of raw operator strings. For("room", RangeOperators.EQUAL)on a scalar column you need thebtree_gistextension (BtreeGistExtension()). -
index_type:GiST(default),Hash(6.1) orSPGiST, case-insensitive. -
condition: aQrestricting the constraint to a subset of rows (condition=Q(cancelled=False)), with the same database restrictions as a partial index. -
deferrable:Deferrable.DEFERREDorDeferrable.IMMEDIATE; a deferred constraint is enforced at the end of the transaction, with a performance penalty. -
include: non-key columns for index-only scans (GiST and SP-GiST). -
violation_error_code(5.0) andviolation_error_message: used when model validation raisesValidationError. -
OpClass()can set a custom operator class for any expression.
A violation raises IntegrityError at save time, and exclusion constraints are also checked by model validation
(full_clean(), ModelForm). The model and DDL are the Booking model and the CREATE TABLE … EXCLUDE USING GIST in the
earlier listings. When a range is stored as two columns instead of a range field, build the range in the expression:
from django.contrib.postgres.constraints import ExclusionConstraint
from django.contrib.postgres.fields import DateTimeRangeField, RangeBoundary, RangeOperators
from django.db import models
from django.db.models import Func, Q
class TsTzRange(Func):
function = "TSTZRANGE"
output_field = DateTimeRangeField()
class Slot(models.Model):
room = models.ForeignKey("pgdemo.Room", on_delete=models.CASCADE)
start = models.DateTimeField()
end = models.DateTimeField()
cancelled = models.BooleanField(default=False)
class Meta:
constraints = [
ExclusionConstraint(
name="exclude_overlapping_slots",
expressions=[
(TsTzRange("start", "end", RangeBoundary()), RangeOperators.OVERLAPS),
("room", RangeOperators.EQUAL),
],
condition=Q(cancelled=False),
),
]
CONSTRAINT "exclude_overlapping_slots" EXCLUDE USING GIST ((TSTZRANGE("start", "end", '[)')) WITH &&, "room_id" WITH =) WHERE (NOT "cancelled")
Source: Django: ExclusionConstraint
check, makemigrations and sqlmigrate were executed for this model; the constraint itself was not enforced against a
server. The test that proves it, which needs PostgreSQL (not run):
# pgdemo/tests.py -- needs PostgreSQL with btree_gist (created by the 0001 migration)
import datetime as dt
from unittest import skipUnless
from django.db import IntegrityError, connection, transaction
from django.db.backends.postgresql.psycopg_any import DateTimeTZRange
from django.test import TestCase
from .models import Booking, Room
def _span(hour_from: int, hour_to: int) -> DateTimeTZRange:
day = dt.datetime(2026, 11, 5, tzinfo=dt.UTC)
return DateTimeTZRange(day.replace(hour=hour_from), day.replace(hour=hour_to))
@skipUnless(connection.vendor == "postgresql", "exclusion constraints need PostgreSQL")
class BookingExclusionTests(TestCase):
def setUp(self):
self.room = Room.objects.create(name="Reading room")
Booking.objects.create(room=self.room, timespan=_span(18, 20))
def test_overlap_in_same_room_is_rejected(self):
with self.assertRaises(IntegrityError), transaction.atomic():
Booking.objects.create(room=self.room, timespan=_span(19, 21))
def test_adjacent_range_is_allowed(self):
Booking.objects.create(room=self.room, timespan=_span(20, 22)) # [18,20) and [20,22) do not overlap
def test_cancelled_booking_does_not_block(self):
Booking.objects.create(room=self.room, timespan=_span(18, 20), cancelled=True)
Aggregates, expressions and functions
The aggregates in django.contrib.postgres.aggregates have no default alias, so you always name them
(aggregate(arr=ArrayAgg("somefield"))). All accept filter=, and most accept default=.
| Class | Returns | Note |
|---|---|---|
|
A list (including nulls) |
Use |
|
A JSON array, queryable with key and index lookups |
Combine with |
|
Values joined by a delimiter |
The PostgreSQL class is deprecated since 6.0 for |
|
True if all / at least one value is true |
|
|
Bitwise results |
Deprecated since 6.1 for the generally available classes in |
Statistical aggregates ( |
Correlation, regression statistics |
Not detailed here; see the aggregates reference |
Since 5.0, ArrayAgg, JSONBAgg and StringAgg return None instead of [], [] and '' when there are no rows, unless
you pass default=. Related expressions:
-
ArraySubquery(queryset)(django.contrib.postgres.expressions) builds an array with PostgreSQL’sARRAY(…)constructor from avalues()queryset with one column. UnlikeArrayAggit is not an aggregate, so it needs noGROUP BY. -
TransactionNow()(django.contrib.postgres.functions) is the time the current transaction started, whereas Django’sNow()is the time of the current statement; only the outermostatomic()sets it. -
RandomUUID()returns a version 4 UUID; use the cross-databaseUUID4(6.1) instead.
from django.contrib.postgres.aggregates import ArrayAgg, JSONBAgg
from django.contrib.postgres.expressions import ArraySubquery
from django.db.models import OuterRef, StringAgg, Value
from django.db.models.functions import JSONObject
Author.objects.annotate(titles=ArrayAgg("books__title", order_by="books__title"))
Author.objects.annotate(
titles=StringAgg("books__title", delimiter=Value(", "), order_by="books__title")
)
books = Book.objects.filter(author=OuterRef("pk")).values(json=JSONObject(title="title", pages="pages"))
Author.objects.annotate(books_json=ArraySubquery(books))
ARRAY_AGG("pgdemo_book"."title" ORDER BY "pgdemo_book"."title") AS "titles" ... GROUP BY "pgdemo_author"."id"
ARRAY(SELECT JSON_OBJECT('title' VALUE "U0"."title", 'pages' VALUE "U0"."pages" RETURNING JSONB) AS "json"
FROM "pgdemo_book" "U0" WHERE "U0"."author_id" = ("pgdemo_author"."id")) AS "books_json"
Compiled only (django.db.models.StringAgg with a Value delimiter compiled to STRING_AGG(…, ORDER BY …) on the
PostgreSQL backend). On the 5.2 LTS, django.db.models.StringAgg does not exist yet: import the class from
django.contrib.postgres.aggregates there. The portable aggregates and window functions belong to Aggregation, Expressions & Functions.
Full-text search
PostgreSQL’s full-text search compares a tsvector (a document reduced to normalised lexemes, stemmed and without stop
words) with a tsquery. Django exposes both through django.contrib.postgres.search. It is "inside your database", so
it combines with relational filters and needs no extra service; it is less powerful than a dedicated engine
(Elasticsearch, Solr), which you would feed through a translation layer. See the
database reference for those engines and
PostgreSQL pgvector for semantic search in the same database.
The search lookup and SearchVector
The simplest search is a lookup on one column, which builds a to_tsvector from the column and a plainto_tsquery from the
term, both in the database’s default configuration (needs django.contrib.postgres in INSTALLED_APPS):
from django.contrib.postgres.search import SearchVector
Book.objects.filter(summary__search="cheese")
Book.objects.annotate(search=SearchVector("title", "summary")).filter(search="django")
WHERE to_tsvector(COALESCE("pgdemo_book"."summary", '')) @@ (plainto_tsquery('cheese'))
WHERE to_tsvector(COALESCE("title", '') || ' ' || COALESCE("summary", '')) @@ (plainto_tsquery('django'))
Source: Django: The search lookup
Compiled only. SearchVector(*expressions, config=None, weight=None) takes field names or expressions, concatenates
several arguments with a space, and vectors can be added (SearchVector("a") + SearchVector("b")). The relation-spanning
form (SearchVector("body_text", "blog__tagline")) works too.
SearchQuery, user input and Lexeme
SearchQuery(value, config=None, search_type="plain") turns text into a tsquery. The search_type values:
search_type |
Meaning |
|---|---|
|
The terms are separate keywords, all of which must match (after stemming). |
|
The terms form one phrase, in order. |
|
A |
|
Web-search syntax (quoted phrases, |
Queries combine with &, | and ~. Passing raw user input to the raw type lets a user inject operators; plain and
websearch are the safe choices for a search box. Since 6.0, Lexeme(value, output_field=None, *, invert=False,
prefix=False, weight=None) makes operators safe with untrusted strings: each lexeme is escaped, and lexemes combine
with &, | and ~, with prefix matching and weights.
from django.contrib.postgres.search import Lexeme, SearchQuery
Book.objects.filter(search_vector=SearchQuery("django -flask", search_type="websearch", config="english"))
Book.objects.filter(search_vector=SearchQuery(Lexeme("django") & ~Lexeme("flask"), config="english"))
WHERE "pgdemo_book"."search_vector" @@ (websearch_to_tsquery('english'::regconfig, 'django -flask'))
WHERE "pgdemo_book"."search_vector" @@ (to_tsquery('english'::regconfig, ('django' & !'flask')))
Source: Django: SearchQuery
Compiled only. Lexeme exists from 6.0; on a 5.2 LTS project use plain/websearch and do not build raw queries from
user input.
Ranking and headlines
SearchRank(vector, query, weights=None, normalization=None, cover_density=False) scores how well a vector matches: it
considers frequency, proximity and the weight of the field in which the term appears. normalization is a bit mask (combine
with Value(2).bitor(Value(4))); cover_density=True takes the proximity of terms into account. Weights A, B, C,
D default to 1.0, 0.4, 0.2, 0.1; pass weights=[d, c, b, a] to change them. SearchHeadline(expression, query, config=None,
start_sel=None, stop_sel=None, max_words=None, min_words=None, short_word=None, highlight_all=None, max_fragments=None,
fragment_delimiter=None) returns the text with the matched terms wrapped; the defaults are PostgreSQL’s (<b>, </b>).
The result of SearchHeadline is an HTML-looking string built from user-controlled text: if you set start_sel and
stop_sel to markup, escape the surrounding text yourself before marking it safe in a template.
from django.contrib.postgres.search import SearchHeadline, SearchQuery, SearchRank
from django.db.models import F, Value
query = SearchQuery("django orm", config="english")
results = (
Book.objects.filter(search_vector=query)
.annotate(
rank=SearchRank(F("search_vector"), query, normalization=Value(2).bitor(Value(4))),
headline=SearchHeadline("summary", query, config="english", start_sel="<mark>", stop_sel="</mark>"),
)
.order_by("-rank")
.defer("search_vector")
)
SELECT ..., ts_rank("pgdemo_book"."search_vector", plainto_tsquery('english'::regconfig, 'django orm'), (2 | 4)) AS "rank",
ts_headline('english'::regconfig, "pgdemo_book"."summary", plainto_tsquery('english'::regconfig, 'django orm'),
<options string carrying StartSel and StopSel>) AS "headline"
FROM "pgdemo_book"
WHERE "pgdemo_book"."search_vector" @@ (plainto_tsquery('english'::regconfig, 'django orm'))
ORDER BY 9 DESC
Source: Django: SearchRank
Compiled only (the options argument of ts_headline is abbreviated here). The defer("search_vector") is deliberate: a
GeneratedField is loaded like any other column, so without it every search row drags the tsvector across the wire.
Configurations and weights
config on SearchVector, SearchQuery and SearchHeadline chooses the language parser and dictionaries
(config="english"); it can also be an expression such as F("blog__language") so each row is processed in its own
language. Vector and query must use the same configuration to match as expected. Weights are set per vector before combining:
vector = SearchVector("title", weight="A", config="english") + SearchVector("summary", weight="B", config="english")
rank = SearchRank(vector, SearchQuery("cheese", config="english"), weights=[0.2, 0.4, 0.6, 0.8])
Source: Django: Weighting queries
A stored vector with GeneratedField
Computing to_tsvector for every row on every query is fine for a few hundred rows. The documentation recommends, once you
search more than that, either a functional GinIndex(SearchVector(…)) (the example in PostgreSQL index types; every query must
then use the same expression) or a stored column. The stored column is a SearchVectorField. Older code keeps it
populated with a trigger; since 5.0 a GeneratedField lets PostgreSQL maintain it, with no trigger and no signal to forget.
That is the search_vector field in the models above; the generated DDL is
"search_vector" tsvector GENERATED ALWAYS AS ((setweight(to_tsvector('english'::regconfig, COALESCE("title", '')), 'A')
|| setweight(to_tsvector('english'::regconfig, COALESCE("summary", '')), 'B'))) STORED
CREATE INDEX "book_search_vector_idx" ON "pgdemo_book" USING gin ("search_vector");
Source: Django: Using GeneratedField
The migration was generated and its SQL printed here; the column was not created on a server. What to know:
-
The column is read-only. Do not assign to it; it is recomputed when
titleorsummarychange, including throughQuerySet.update()andbulk_update(). -
The configuration is part of the generation expression (
config="english"above), so one column serves one language; use several columns, or the per-queryconfigexpressions described earlier, for several languages. -
Since 6.0 Django refreshes generated values from the database after
save()on PostgreSQL (throughRETURNING), sobook.search_vectoris current right after saving. -
Adding a generated column to a large existing table rewrites it; plan the migration like any other table rewrite (see Migrations).
-
Since 6.1
GeneratedFieldsupports virtual columns (db_persist=False) on PostgreSQL 18+. A column that a GIN index covers has to be stored, so this page usesdb_persist=True; check PostgreSQL’s documentation for the index support of virtual generated columns before choosing otherwise. -
Searching against the stored column is just
Book.objects.filter(search_vector=query), as in the previous examples; the olderBook.objects.update(search_vector=SearchVector("body_text"))pattern is for trigger-maintained columns only.
A test for it, which needs PostgreSQL (not run):
# pgdemo/tests.py -- needs PostgreSQL
from unittest import skipUnless
from django.contrib.postgres.search import SearchQuery, SearchRank
from django.db import connection
from django.db.models import F
from django.test import TestCase
from .models import Author, Book
@skipUnless(connection.vendor == "postgresql", "full-text search needs PostgreSQL")
class BookSearchTests(TestCase):
@classmethod
def setUpTestData(cls):
author = Author.objects.create(name="A. Writer")
cls.title_hit = Book.objects.create(author=author, title="Django cheese", summary="unrelated")
cls.summary_hit = Book.objects.create(author=author, title="Unrelated", summary="all about cheese")
Book.objects.create(author=author, title="Nothing", summary="nothing here")
def test_title_outranks_summary(self):
query = SearchQuery("cheese", config="english")
found = list(
Book.objects.filter(search_vector=query)
.annotate(rank=SearchRank(F("search_vector"), query))
.order_by("-rank")
)
self.assertEqual(found, [self.title_hit, self.summary_hit]) # weight A beats weight B
def test_generated_vector_follows_updates(self):
Book.objects.filter(pk=self.summary_hit.pk).update(summary="bread")
query = SearchQuery("cheese", config="english")
self.assertEqual(list(Book.objects.filter(search_vector=query)), [self.title_hit])
Source: Django: Search
Trigram similarity and unaccent
A trigram is a group of three consecutive characters; two strings are similar when they share many. Trigrams find
"Middlesborough" when the row says "Middlesbrough" and suit short strings (names, titles) where users type something
close. They need the pg_trgm extension (TrigramExtension() in a migration), and unaccent needs its own
(UnaccentExtension()).
| Tool | Use |
|---|---|
|
Lookup ( |
|
The greatest number of shared trigrams with any substring of the field; the strict variant forces word boundaries. |
|
Annotation with the similarity (0 to 1) for ordering and thresholds. |
|
Word variants; note that the string comes first. |
|
The complement, for |
|
Accent-insensitive lookup, chainable with other lookups; it generally forces a full table scan. |
from django.contrib.postgres.search import TrigramSimilarity
Author.objects.filter(name__trigram_similar="Irurueta")
Author.objects.annotate(sim=TrigramSimilarity("name", "Irurueta")).filter(sim__gt=0.3).order_by("-sim")
Author.objects.filter(name__unaccent__icontains="helene")
WHERE "pgdemo_author"."name" % 'Irurueta'
SELECT ..., SIMILARITY("pgdemo_author"."name", 'Irurueta') AS "sim" FROM "pgdemo_author"
WHERE SIMILARITY("pgdemo_author"."name", 'Irurueta') > 0.3 ORDER BY 3 DESC
WHERE UPPER(UNACCENT("pgdemo_author"."name")::text) LIKE '%' || UPPER(REPLACE(REPLACE(REPLACE((UNACCENT('helene')), E'\\', E'\\\\'), E'%', E'\\%'), E'_', E'\\_')) || '%'
Source: Django: Trigram similarity
Compiled only. The trigram index shown in the models and PostgreSQL index types (GinIndex(OpClass(Upper("name"),
name="gin_trgm_ops"))) serves the % operator on UPPER(name); a lookup on name itself needs an index on name.
Trigram similarity is asymmetric in practice: the documentation’s example shows that a short search string matches a
longer name poorly, so choose between trigram_similar, the word variants and unaccent against your own data, and tune
the similarity threshold in PostgreSQL if the default does not suit.
Sync and async
All of the fields, lookups and expressions above are ordinary query building blocks, so they work with the async ORM
(await Book.objects.filter(…).acount(), async for) exactly as they do synchronously; the compiled SQL is identical, as
the portable test showed on SQLite. What differs is the connection model: each async ORM call runs in a thread with the
connection of its request, concurrent requests in one process share the connection pool, persistent connections
(CONN_MAX_AGE) should be disabled, and transactions do not yet work in async mode. So for an ASGI deployment of
Bookshelf: psycopg 3 with "pool" in OPTIONS, CONN_MAX_AGE = 0, a pool sized to the in-flight queries you want, and
transactional code kept in a synchronous function called with sync_to_async(). See
Introduction & Architecture for where the database sits in the request
cycle.
Pitfalls checklist
-
Forgetting the app or the extension.
django.contrib.postgresinINSTALLED_APPSfor lookups; the extension in a migration for the type or operator class (type "hstore" does not exist,operator class "gin_trgm_ops" does not exist). The extension operation must run before the first thing that needs it. -
default=[]anddefault={}. Mutable defaults shared between instances; uselistanddict. -
Typos in
hstorelookups become key lookups silently. -
unaccentandicontainscan force full scans; measure before relying on them. -
A GIN or GiST index that the query cannot use. Plain
db_index=Trueis a B-tree; functional indexes must match the query’s expression; confirm withexplain(). -
Checking overlap in Python. Racy; use
ExclusionConstraint. And remember it needsbtree_gistfor scalar columns. -
CREATE INDEX CONCURRENTLYin an atomic migration fails; setatomic = Falseand keep the operation alone. -
AddConstraintNotValidandValidateConstraintin one migration defeats the purpose. -
poolwithoutpsycopg[pool], withCONN_MAX_AGEother than0, or with psycopg2. -
Persistent connections under ASGI, and connections left open by threads or scripts outside the request cycle.
-
Transaction-pooling poolers and
iterator(): disable server-side cursors. -
Explicit primary keys in fixtures or imports without
sqlsequenceresetafterwards. -
Searching with different configurations for vector and query, or with
rawqueries built from user input. -
Trusting SQLite in tests for PostgreSQL-only features: the code that passes there is the fallback branch, not the feature.
-
StringAgg,ArrayAgg(ordering=…)and the bit aggregates fromdjango.contrib.postgres.aggregatesin new code: deprecated or removed (see below).
Testing on PostgreSQL
Run the suite on the database you deploy to. In a CI job use a PostgreSQL service container (version 15 or newer) and the
same DATABASES as production with a different NAME; Django creates and drops the test_ database, running the
migrations (and so the extension operations). Mark PostgreSQL-only tests with unittest.skipUnless(connection.vendor ==
"postgresql", …) as in the examples above, so the suite still runs on a laptop with SQLite. skipUnlessDBFeature is the
alternative for features Django models as database flags. For fixtures, factories and test data, see
Fixtures, Serialization & Initial Data.
Where the books are outdated
The four reference books for this section target Django 1.11 to 3.x. Where they touch databases the differences are:
| Older practice | Current practice | Changed in |
|---|---|---|
|
psycopg 3 ( |
4.2 |
|
|
3.1 added it, 4.0 removed the PostgreSQL class |
|
|
deprecated 4.2, removed 5.1 |
|
|
|
A trigger plus |
A |
5.0 |
Unique-together plus Python checks to prevent overlapping periods |
|
3.0 (introduced), many options added since ( |
|
|
deprecated 5.2, removed 6.1 |
|
|
6.0 |
Building search |
|
|
Blocking |
|
3.0 |
The books' blog and search chapters remain a good conceptual guide to SearchVector, SearchRank, weights and trigram
similarity; the class names are still current, but check every import and keyword argument against the version of Django you
run before copying it, and treat any settings.DATABASES shown without a pool, health checks or an environment-driven
password as a teaching simplification.
What changed recently
| Version | Change |
|---|---|
3.0 |
|
3.1 |
|
4.0 |
Connect through a service name; |
4.1 |
Functional unique constraints and |
4.2 |
psycopg 3 supported; |
5.0 |
|
5.1 |
PostgreSQL connection pools; |
5.2 (LTS) |
Oracle connection pools; |
6.0 |
|
6.1 |
PostgreSQL 15 minimum; |
Source: Django 6.1 release notes
Before upgrading, run the tests with python -Wd manage.py test so deprecation warnings appear; 6.2 (the next LTS, planned for April
2027) is the last release under the old versioning, and from 2028 releases are named by year.