PostgreSQL: indexes and tables¶
The index types PostgreSQL has beyond the B-tree every database has, and partitioned tables.
Index types¶
hare.dialects.postgresql.indexes — all subclass PartialIndex
with an identical constructor, so every one of these also supports condition=:
GinIndex, GistIndex, BrinIndex, BloomIndex, HashIndex, SpGistIndex. Their base,
PostgresqlIndex, is the one to subclass for an index of another PostgreSQL access method.
They are indexes of PostgreSQL only, as HnswIndex/IvfflatIndex are: on a database of another dialect
creating or dropping one — by generate_schemas() or a migration — raises UnSupportedError before any
SQL. Declare that database's own index in the model, or change the migration. SQLite's own indexes are
the same the other way round — FullTextIndex
and SpatialiteIndex. On ClickHouse an Index is a data
skipping index — see ClickHouse.
from hare.dialects.postgresql.indexes import GinIndex
class Book(Model):
tags = fields.JSONField(default=list, field_type=list[str])
class Meta:
indexes = (GinIndex(fields=("tags",)),)
Partitioned tables¶
PostgresqlTableOptions(partitioning=...) makes the table a partitioned one: it is created
PARTITION BY <strategy> (<key columns>), and each partition as a table of its own named
<table>_<partition name> (within 63 bytes, like every name hare generates). The classes live in
hare.dialects.postgresql.partitioning:
import datetime
from hare import fields
from hare.dialects.postgresql.partitioning import (
HashPartitioning, ListPartition, ListPartitioning, RangeBound, RangePartition, RangePartitioning,
)
from hare.dialects.postgresql.postgresql_table_options import PostgresqlTableOptions
from hare.models import Model
class ReadingHistory(Model):
reader_id = fields.BigIntField()
book_id = fields.BigIntField()
pk = fields.CompositePrimaryKey("reader_id", "book_id")
class Meta:
table_options = [
PostgresqlTableOptions(
# readinghistory_p0 ... readinghistory_p31, FOR VALUES WITH (MODULUS 32, REMAINDER i)
partitioning=HashPartitioning(fields=("reader_id",), partition_count=32),
storage_parameters={"autovacuum_vacuum_scale_factor": 0.02},
)
]
class StoreSale(Model):
region = fields.CharField(max_length=10)
number = fields.IntField()
pk = fields.CompositePrimaryKey("region", "number")
class Meta:
table_options = [
PostgresqlTableOptions(
partitioning=ListPartitioning(
fields=("region",),
partitions=[
ListPartition("west", values=["us", "ca"]), # storesale_west FOR VALUES IN ('us', 'ca')
ListPartition("east", values=["jp"]),
],
default_partition="other", # storesale_other DEFAULT - every other region
)
)
]
class LoanRecord(Model):
day = fields.DateField()
number = fields.IntField()
pk = fields.CompositePrimaryKey("day", "number")
class Meta:
table_options = [
PostgresqlTableOptions(
partitioning=RangePartitioning(
fields=("day",),
partitions=[
RangePartition("old", from_values=(RangeBound.MINVALUE,), to_values=(datetime.date(2026, 1, 1),)),
RangePartition(
"y2026", from_values=(datetime.date(2026, 1, 1),), to_values=(datetime.date(2027, 1, 1),)
),
],
default_partition="later",
)
)
]
| Class | Arguments | Partitions |
|---|---|---|
HashPartitioning |
fields, partition_count (1 to 1024) |
<table>_p0 ... <table>_p<n-1>, rows spread evenly by a hash of the key. |
ListPartitioning |
fields (one), partitions (ListPartition(name, values)), default_partition |
Each partition holds the rows of the key values it lists; None among them means the rows with a NULL key. |
RangePartitioning |
fields, partitions (RangePartition(name, from_values, to_values)), default_partition |
Each partition holds the rows from its lower bound up to, not including, its upper one — one value per key column. RangeBound.MINVALUE/RangeBound.MAXVALUE stand below/above every value. |
fields name the key: fields with a column, or a foreign key, which stands for its key column(s).
default_partition is the name of the partition for the rows no other one holds; without it, a row no
partition holds is refused by the database (IntegrityError). The order the partitions are listed in
doesn't matter.
What PostgreSQL requires is checked before any DDL is written — when the table is created, and when a
later migration adds a constraint, an index or a field or changes a field (AddConstraint, AddIndex,
AddField, AlterField) — ConfigurationError names the problem:
- the key fields exist and have columns;
- the primary key, every unique field,
UniqueConstraint, uniqueIndexandExclusionConstraint(with=) includes every key column — PostgreSQL enforces each within one partition. A model whose key is one generatedidcan only be partitioned by thatid; - a partitioned table can't be
unlogged; - the partitions' names differ, a
ListPartitioningvalue is listed once,RangePartitions don't overlap and give one value per key column.
An ExclusionConstraint on a partitioned table needs PostgreSQL 17 (UnSupportedError on an older
server). Foreign keys to and from a partitioned table work as on a plain one.
A partitioned table holds no rows itself, so storage_parameters are set on each partition (at creation
and when they change), and tablespace on the table — where new partitions go — and on each partition.
Indexes and constraints are created on the table and reach every partition. A concurrent index
(AddIndex(concurrently=True)) can't be built on a partitioned table as a whole: hare creates it on the
table alone, builds it concurrently on each partition and attaches each — it is valid once the last one
is attached; dropping it is never concurrent.
How a change reaches the database:
| Change | Migration |
|---|---|
A ListPartition/RangePartition or the default partition added |
AddPartition — a new, empty partition. PostgreSQL refuses it while the default partition holds rows that belong to it. |
| One removed | RemovePartition — the partition is detached and dropped with its rows; makemigrations warns. |
| A partition's values or bounds changed | RemovePartition and AddPartition of the same name — its rows are lost. |
storage_parameters, tablespace |
AlterModelOptions, in place on every partition. |
Partitioning added or removed, another strategy, key or partition_count |
AlterModelOptions that creates the table anew: the rows are copied, the indexes, constraints, comments, triggers and the foreign keys of other tables referencing it are set again. It rewrites the whole table under an exclusive lock. |
Meta.table changed, RenameModel |
The partitions are renamed with the table. |
On SQLite (and any other dialect) the PostgresqlTableOptions entry is not used: the table is a plain
one, and the partition operations only change the migration state there.