Cheatsheet for Postgres native ARRAY columns.

Column

from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy import String, Integer

class Post(Base):
    id: Mapped[int] = mapped_column(primary_key=True)
    tags: Mapped[list[str]] = mapped_column(ARRAY(String))
    scores: Mapped[list[int]] = mapped_column(ARRAY(Integer))
    matrix: Mapped[list[list[int]]] = mapped_column(ARRAY(Integer, dimensions=2))

Queries

from sqlalchemy import text, func

# Contains
stmt = select(Post).where(Post.tags.contains(["python", "fastapi"]))   # array @> array

# Overlaps (has any element in common)
stmt = select(Post).where(Post.tags.overlap(["python", "rust"]))

# Single-element ANY
stmt = select(Post).where(text("'python' = ANY(tags)"))

# Length
stmt = select(Post).where(func.array_length(Post.tags, 1) > 5)

# Specific index (1-based)
stmt = select(Post.tags[1])

Append / remove

# Append
await session.execute(
    update(Post).where(Post.id == 1).values(tags=Post.tags + ["new-tag"])
)

# Remove element by value (use array_remove)
await session.execute(
    update(Post).where(Post.id == 1).values(tags=func.array_remove(Post.tags, "old-tag"))
)

# Prepend
await session.execute(
    update(Post).where(Post.id == 1).values(tags=text("ARRAY['new-tag'] || tags"))
)

GIN index

__table_args__ = (
    Index("ix_posts_tags_gin", "tags", postgresql_using="gin"),
)

Efficient for containment / overlap queries.

Unnest (rows from array)

stmt = select(func.unnest(Post.tags).label("tag")).where(Post.id == 1)

Useful in subqueries or join lateral.

array_agg (aggregate to array)

stmt = (
    select(Post.author_id, func.array_agg(Post.tags))
    .group_by(Post.author_id)
)

Pass array as parameter

ids = [1, 2, 3]
stmt = select(User).where(User.id == func.any_(ids))

func.any_(array) for = ANY (array).

Bulk insert with array column

await session.execute(insert(Post), [
    {"tags": ["a", "b"], "title": "..."},
    {"tags": ["c"], "title": "..."},
])

Constraints on arrays

__table_args__ = (
    CheckConstraint("array_length(tags, 1) <= 20", name="ck_posts_max_tags"),
)

Trigram on array elements (combine with unnest + trigram)

For text-search within tags:

SELECT * FROM posts p
WHERE EXISTS (
    SELECT 1 FROM unnest(p.tags) t WHERE t ILIKE '%py%'
);

In SQLA:

from sqlalchemy import literal, exists, select as core_select

stmt = select(Post).where(
    exists(
        core_select(literal(1)).select_from(func.unnest(Post.tags).alias("t"))
        .where(text("t ILIKE :p"))
    ),
    {"p": "%py%"},
)

Or denormalize tags into a separate tags table for richer queries.

Cross-DB note

MySQL / SQLite don’t have native arrays. Use JSON column with a list, or normalize.

When NOT to use ARRAY

  • Need to query by individual elements with rich predicates (use a join table).
  • Many writes to the same row (write amplification of full rewrite).
  • Cross-DB portability.

Common mistakes

  • 1-based vs 0-based indexing — Postgres arrays are 1-based.
  • array_length(arr, 1) returns NULL for empty arrays — check.
  • Mutating ARRAY by re-assigning whole array — sometimes you want array_append/array_remove.
  • Missing GIN — slow contains.

Read this next

If you want my unnest + trigram + tag-search example, it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .