Querying Postgres array columns with ANY and contains

Postgres ARRAY columns are filtered with any_() for a single value, .contains() when every element must be present and .overlap() when any will do — all three index-assisted by a GIN index, while column == value silently compares the whole array. This guide belongs to querying Postgres JSONB, arrays and full-text search.

Quick Answer

Map the column with ARRAY, then pick the predicate that matches the question.

Four array predicates Four tiles. any_ with an equality test generates value equals ANY of the column, matching one element. contains generates the array-contains operator, requiring every element on the right. overlap generates the overlaps operator, requiring at least one element in common. And array_length compares the number of elements. col.any_(x) / x == any_(col) x = ANY(tags) does it contain x col.contains([a, b]) tags @> ARRAY[a,b] contains all of them col.overlap([a, b]) tags && ARRAY[a,b] at least one in common func.array_length(col, 1) array_length(tags, 1) how many elements All four are index-assisted by a GIN index, except array_length.
from sqlalchemy import String
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY
from sqlalchemy.orm import Mapped, mapped_column

from shop.models import Base


class Product(Base):
    __tablename__ = "products"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    tags: Mapped[list[str]] = mapped_column(PG_ARRAY(String), default=list)
from sqlalchemy import any_, select

# Does the array contain this one value?  ->  'sale' = ANY(products.tags)
stmt = select(Product).where("sale" == any_(Product.tags))

# Does it contain all of these?           ->  products.tags @> ARRAY['sale','clearance']
stmt = select(Product).where(Product.tags.contains(["sale", "clearance"]))

# Does it contain any of these?           ->  products.tags && ARRAY['sale','new']
stmt = select(Product).where(Product.tags.overlap(["sale", "new"]))

# How many elements?                      ->  array_length(products.tags, 1) > 3
from sqlalchemy import func

stmt = select(Product).where(func.array_length(Product.tags, 1) > 3)

And the index that makes the first three fast:

CREATE INDEX ix_products_tags_gin ON products USING gin (tags);

The mistake to avoid is Product.tags == "sale", which compares the whole array to a single value. Postgres will either reject it or coerce it, and neither is what was meant.

Execution Context & Async Workflow Integration

An array column is a single value that happens to hold several elements, so every predicate is about the array as a whole.

GIN over an array column Five steps. A GIN index stores one entry per distinct element, each pointing at the rows holding it. A containment query looks up each requested element. The postings lists are intersected for contains, or unioned for overlaps. The surviving rows are fetched and rechecked. Without the index the same query is a sequential scan over every row. GIN: one entry per element each points at its rows not one entry per row look up each element sale, clearance two index lookups combine the postings intersect for @> union for && fetch and recheck the heap rows the operator is rechecked without it a sequential scan A GIN index is what makes @> and && usable on a large table.

any_() is the SQL ANY construct. It reads backwards compared with Python — the scalar goes on the left — because that is the shape of the generated SQL, 'sale' = ANY(tags). SQLAlchemy also offers Product.tags.any("sale") on the PostgreSQL ARRAY type, which generates the same thing; the any_() form is the one that composes with other operators (<, LIKE) and is worth learning as the default.

.contains() generates @>, which asks whether the left array holds every element of the right. .overlap() generates &&, which asks whether they share at least one. Getting these two the wrong way round is the commonest bug in array queries, and it fails silently — the query runs and returns plausible, wrong rows. The mental shortcut: contains is an AND, overlap is an OR.

Indexing. A GIN index over an array stores one entry per distinct element, each with a list of the rows containing it. @>, && and = ANY() all use it. array_length does not, and neither does anything that transforms the column first — unnest(tags) in a subquery, or array_to_string(tags, ',') LIKE '%sale%', both discard the index. That is the same rule as for expression indexes generally, covered in creating partial and expression indexes from SQLAlchemy models.

Declare the index on the model so Alembic generates it:

from sqlalchemy import Index


class Product(Base):
    __tablename__ = "products"
    __table_args__ = (
        Index("ix_products_tags_gin", "tags", postgresql_using="gin"),
    )

Async specifics. Array columns behave identically under asyncpg; the values arrive as Python lists with no extra round trip, because the array is part of the row. That is the main performance argument for an array over a child table: no join, no second query, no loader strategy to choose. A product list page that needs each product's tags gets them for free, where a child table would need a selectinload and a second statement — the trade-off weighed in using selectinload vs joinedload for N+1 prevention.

One caveat about nulls. 'sale' = ANY(tags) is false when tags is NULL, not unknown-then-false in a surprising way, so the filter behaves. But array_length(NULL, 1) is NULL, so > 3 excludes those rows and IS NULL is needed to find them. Defaulting the column to an empty array — server_default=text("'{}'::text[]") — removes the distinction between "no tags" and "unknown", which is usually what the domain means.

Aggregating, Unnesting and Updating Arrays

Three operations round out day-to-day use.

Array column or child table Left: an array column keeps the values in the row, needs no join, and is fast to read whole, but cannot be constrained by a foreign key, has no per-value metadata and must be rewritten entirely to change one element. Right: a child table gives referential integrity, per-row columns and cheap single-value updates, at the cost of a join. ARRAY column no join to read GIN handles containment no foreign keys rewritten whole on update child table a join to read B-tree on the value foreign keys and per-row data one row updated at a time Arrays suit short, closed, read-mostly lists such as tags or role names.

Building an array from rows. array_agg turns a grouped set of rows into one array, which is how a child table can be read with array ergonomics:

from sqlalchemy import func, select

from shop.models import Order, OrderItem

stmt = (
    select(
        Order.id,
        func.array_agg(OrderItem.sku).label("skus"),
    )
    .join(OrderItem, OrderItem.order_id == Order.id)
    .group_by(Order.id)
)

array_agg with DISTINCT and an internal ordering is often what a report wants:

from sqlalchemy.dialects.postgresql import aggregate_order_by

stmt = select(
    Order.id,
    func.array_agg(
        aggregate_order_by(func.distinct(OrderItem.sku), OrderItem.sku)
    ).label("skus"),
).join(OrderItem).group_by(Order.id)

aggregate_order_by comes from sqlalchemy.dialects.postgresql and is the only way to get ORDER BY inside an aggregate.

Expanding an array into rows. func.unnest used as a table function gives one row per element, which is how to count tag frequencies:

from sqlalchemy import func, select, true

tag = func.unnest(Product.tags).table_valued("tag")

stmt = (
    select(tag.c.tag, func.count().label("products"))
    .select_from(Product)
    .join(tag, true())
    .group_by(tag.c.tag)
    .order_by(func.count().desc())
)

table_valued() is what makes a set-returning function usable in the FROM clause. Note that this query scans the table by design — it is a full aggregation, not a lookup, and no index helps.

Updating an array. Two things surprise people here. First, Postgres has no in-place element update: changing one element rewrites the whole value, and the row with it. Second, SQLAlchemy does not track mutations of a plain ARRAY — appending to the list in Python leaves the attribute looking unchanged, so the flush emits nothing:

product.tags.append("sale")      # not detected: no UPDATE is emitted
await session.commit()           # silently does nothing

Three fixes, in order of preference. Reassign the attribute, which is explicit and needs no extra machinery:

product.tags = [*product.tags, "sale"]
await session.commit()

Use MutableList, which tracks in-place changes at the cost of wrapping every value:

from sqlalchemy.ext.mutable import MutableList


class Product(Base):
    tags: Mapped[list[str]] = mapped_column(
        MutableList.as_mutable(PG_ARRAY(String)), default=list
    )

Or push the change into SQL, which avoids reading the row at all and is race-free under concurrency:

from sqlalchemy import func, update

await session.execute(
    update(Product)
    .where(Product.id == product_id)
    .values(tags=func.array_append(Product.tags, "sale"))
)

That last form matters more than it looks. Read-modify-write on an array loses concurrent appends — two requests each read ['new'], each write their own two-element list, and one tag vanishes. array_append in SQL has no such window, the same reasoning as for JSONB updates in querying and indexing JSONB columns in SQLAlchemy. Use array_remove for deletion, and array_cat to merge.

Resolving Warnings, Errors & Common Mistakes

Exact errorRoot CauseProduction Fix
ProgrammingError: operator does not exist: text[] = textProduct.tags == "sale"."sale" == any_(Product.tags).
ProgrammingError: operator does not exist: text[] @> text.contains("sale") with a scalar.Pass a list: .contains(["sale"]).
Rows that should match are missingcontains used where overlap was meant.contains is AND; overlap is OR.
An append is never persistedPlain ARRAY does not track in-place mutation.Reassign the list, use MutableList, or array_append in SQL.
array_length(...) > 0 skips rowsThe column is NULL, and array_length(NULL, 1) is NULL.Default to '{}', or add IS NULL handling.
A sequential scan despite the GIN indexThe column was transformed — unnest, array_to_string.Filter with @>, && or = ANY on the bare column.
DataError: cannot determine type of empty arrayAn empty Python list bound with no type.Cast it: cast([], PG_ARRAY(String)).
Concurrent appends lose tagsRead-modify-write in Python.func.array_append in an UPDATE.
InvalidTextRepresentationError on insertMixed element types in the Python list.Coerce to the declared element type first.
Match one, all, or any Three lanes. Matching a single value uses any_ with equality, which reads naturally and uses the index. Requiring every value in a list uses contains. Requiring any value in a list uses overlap. Writing the wrong one silently returns the wrong rows rather than failing. one value: x == any_(col) the most common filter, and the one people write as col == x by mistake all of a list: col.contains(vals) the array-contains operator, an AND across the requested elements any of a list: col.overlap(vals) the overlaps operator, an OR across the requested elements

Two design points are worth more than a table row.

Use postgresql.ARRAY, not the generic one, on Postgres. sqlalchemy.ARRAY is the cross-dialect base and lacks .contains(), .overlap() and .any(); sqlalchemy.dialects.postgresql.ARRAY has them. Importing the wrong one produces an AttributeError at query-build time, which is at least immediate.

Know when an array is the wrong model. An array cannot have a foreign key, so nothing stops tags holding a value no tag table knows about; there is nowhere to put per-value metadata such as who added it and when; and every change rewrites the row, which on a hot table means bloat. A child table gives all three, at the cost of a join. The heuristic: arrays suit short, closed, read-mostly lists — role names, tag slugs, ISO country codes. A list that grows without bound, or that other tables need to join against, belongs in its own table with the cascade rules described in configuring cascade delete and delete-orphan correctly.

The intermediate position — a child table, read as an array with array_agg, and filtered with a EXISTS subquery — gets integrity and reasonable ergonomics, and is usually the right call when in doubt.

Filtering, Sorting and Paginating by Array Contents

Array filters compose with everything else, and two combinations come up repeatedly.

Four ways to search one array column Four bars giving relative wall-clock cost for finding the rows whose tags array contains one value, on a table of two million rows. A GIN index with the contains operator is the baseline. Overlaps against the same index is similar. Unnesting the array in a subquery loses the index. And a Python-side filter after loading every row is worst by far. GIN index + tags @> ARRAY[x] baseline GIN index + x = ANY(tags) ~1.3x unnest in a subquery, no index ~20x load every row, filter in Python ~33x Indicative, not a benchmark: the gap is between using the GIN index and not.

Faceted filtering. A product search with several optional tag filters builds up predicates conditionally, in the pattern described in building dynamic filters and sorting from API query parameters:

from sqlalchemy import select


def search(any_tags: list[str] | None, all_tags: list[str] | None, q: str | None):
    stmt = select(Product)
    if any_tags:
        stmt = stmt.where(Product.tags.overlap(any_tags))
    if all_tags:
        stmt = stmt.where(Product.tags.contains(all_tags))
    if q:
        stmt = stmt.where(Product.name.ilike(f"%{q}%"))
    return stmt.order_by(Product.id).limit(50)

Both array predicates use the one GIN index, and the planner combines them with any other index it has. A tag-and-text search that needs to be fast on both halves wants the text side indexed too, which is implementing full-text search with tsvector in SQLAlchemy rather than ILIKE.

Ranking by overlap size. "Most relevant" for a tag search usually means "shares the most tags", which is a cardinality of an intersection:

from sqlalchemy import func, select

wanted = ["sale", "new", "clearance"]
tag = func.unnest(Product.tags).table_valued("tag")

matches = (
    select(func.count())
    .select_from(tag)
    .where(tag.c.tag.in_(wanted))
    .scalar_subquery()
    .label("matches")
)

stmt = (
    select(Product, matches)
    .where(Product.tags.overlap(wanted))
    .order_by(matches.desc(), Product.id)
    .limit(20)
)

The where clause does the index work and the ordering expression runs only on the surviving rows, which is the important part: computing the overlap for every row in the table would be a scan. Filter with the indexable operator, rank with the expensive expression.

Pagination. Ordering by an array is legal — Postgres compares element by element — but rarely meaningful, and an array is a poor keyset cursor. Paginate by a scalar column and treat the array as a filter only, which is the keyset approach in paginating large result sets with keyset pagination. Where a rank like the one above drives the order, it must be included in the cursor along with the primary key, because ranks tie.

Frequently Asked Questions

How do I check whether a Postgres array contains a value in SQLAlchemy?

Use any_() with equality: select(Product).where("sale" == any_(Product.tags)), which generates 'sale' = ANY(products.tags). Product.tags == "sale" compares the whole array and is wrong.

What is the difference between contains and overlap?

.contains() generates @> and requires every element you pass to be present — an AND. .overlap() generates && and requires at least one — an OR.

Why is my array append not saved?

A plain ARRAY column does not track in-place list mutation. Reassign the attribute, wrap the type in MutableList.as_mutable(), or issue func.array_append in an UPDATE.

Do array queries use an index?

Yes, with a GIN index on the column: @>, && and = ANY() all use it. array_length and anything that unnests or stringifies the column do not.