Most relational databases treat “one column, one value” as a hard rule, forcing you into a separate join table the moment you need to store multiple values for a single row. PostgreSQL breaks from that convention in a genuinely useful way — nearly any data type in Postgres can be turned into an array type, stored directly in a column, indexed, queried, and manipulated with a full set of dedicated operators and functions. This article covers how arrays actually work in PostgreSQL, when they’re the right tool, and when they’re a trap.
What Are Array Data Types?
An array in PostgreSQL is an ordered collection of values of the same base type, stored in a single column. You can create an array version of essentially any type — built-in or custom — simply by appending square brackets to the type name.
CREATE TABLE products (
product_id serial PRIMARY KEY,
product_name text NOT NULL,
tags text[],
prices_by_region numeric[]
);
Arrays can technically be multi-dimensional in Postgres, though in practice the vast majority of real-world usage is one-dimensional.
Basic Syntax
Array Literals
INSERT INTO products (product_name, tags)
VALUES ('Wireless Mouse', ARRAY['electronics', 'accessories', 'wireless']);
-- equivalent alternate literal syntax
INSERT INTO products (product_name, tags)
VALUES ('Mechanical Keyboard', '{"electronics","accessories","gaming"}');
Both forms produce the same result — ARRAY[...] is generally clearer and less error-prone in application code, while the curly-brace string literal form is common when data comes from exports or external tools.
Declaring Array Length (Advisory Only)
You can write text[5] to suggest a length, but PostgreSQL does not actually enforce array length limits from this syntax — it’s accepted for SQL-standard compatibility but has no real effect. If you need to enforce a fixed array length, you’ll need an explicit CHECK constraint using array_length().
CREATE TABLE fixed_arrays (
id serial PRIMARY KEY,
coordinates numeric[] CHECK (array_length(coordinates, 1) = 3)
);
Accessing Array Elements
PostgreSQL arrays are 1-indexed by default, not 0-indexed — this is the single most common source of off-by-one bugs for developers coming from most programming languages.
SELECT tags[1] FROM products WHERE product_id = 1;
-- returns the FIRST element, e.g. 'electronics'
Slicing:
SELECT tags[1:2] FROM products WHERE product_id = 1;
-- returns the first two elements as a sub-array
Getting the last element safely (since arrays can have varying lengths per row):
SELECT tags[array_length(tags, 1)] FROM products WHERE product_id = 1;
Array Operators
-- Contains: does the left array contain all elements of the right array?
SELECT ARRAY['a','b','c'] @> ARRAY['b','c'];
-- true
-- Is contained by
SELECT ARRAY['b','c'] <@ ARRAY['a','b','c'];
-- true
-- Overlap: do the arrays share at least one element?
SELECT ARRAY['a','b'] && ARRAY['b','c'];
-- true
-- Concatenation
SELECT ARRAY['a','b'] || ARRAY['c','d'];
-- {a,b,c,d}
-- Append a single element
SELECT ARRAY['a','b'] || 'c';
-- {a,b,c}
-- Equality
SELECT ARRAY[1,2,3] = ARRAY[1,2,3];
-- true
The @>, <@, and && operators are what make arrays genuinely queryable in practice — checking “does this row have this tag” or “does this row have any of these tags” without a join.
SELECT product_name FROM products WHERE tags @> ARRAY['wireless'];
SELECT product_name FROM products WHERE tags && ARRAY['gaming', 'wireless'];
Array Functions
SELECT array_length(tags, 1) FROM products; -- number of elements
SELECT cardinality(tags) FROM products; -- also returns element count (simpler for 1D arrays)
SELECT array_append(tags, 'sale') FROM products; -- returns array with 'sale' added
SELECT array_remove(tags, 'wireless') FROM products; -- returns array with that value removed
SELECT array_position(tags, 'gaming') FROM products; -- index of first match, or NULL
SELECT unnest(tags) FROM products; -- expands array into one row per element
unnest() deserves special attention because it’s genuinely one of the most useful functions for working with arrays relationally:
SELECT product_name, unnest(tags) AS tag
FROM products;
This turns your array data into normal rows, letting you GROUP BY, join, or filter on individual elements the same way you would with a proper normalized table — while keeping the compact array storage for the common case.
Updating Array Columns
-- Add an element
UPDATE products
SET tags = array_append(tags, 'clearance')
WHERE product_id = 1;
-- Remove an element
UPDATE products
SET tags = array_remove(tags, 'clearance')
WHERE product_id = 1;
-- Replace the whole array
UPDATE products
SET tags = ARRAY['electronics', 'discontinued']
WHERE product_id = 1;
-- Update a specific index
UPDATE products
SET tags[1] = 'gadgets'
WHERE product_id = 1;
Aggregating Into Arrays
Going the other direction — collapsing multiple rows into a single array — is done with array_agg(), and it’s an extremely common pattern for producing summary or denormalized results:
SELECT customer_id, array_agg(order_id ORDER BY order_date) AS order_history
FROM orders
GROUP BY customer_id;
This is often used specifically to avoid an expensive join-and-group-in-application-code pattern, returning a clean, structured array directly from the query.
Indexing Array Columns
For containment-style queries (@>, <@, &&), a plain B-tree index doesn’t help. You want a GIN index:
CREATE INDEX idx_products_tags ON products USING gin (tags);
With this index in place, a query like WHERE tags @> ARRAY['wireless'] can use an efficient index lookup instead of scanning every row and checking its array contents one by one — the difference becomes dramatic as table size grows.
Multi-Dimensional Arrays
Postgres technically supports multi-dimensional arrays, though they’re much less commonly used in practice:
SELECT ARRAY[[1,2,3],[4,5,6]];
-- a 2x3 array
SELECT (ARRAY[[1,2,3],[4,5,6]])[1][2];
-- 2
In practice, most real applications get more value and clarity from either a one-dimensional array or a proper related table than from multi-dimensional arrays, which can become genuinely difficult to query and reason about.
Arrays vs. a Related Table: The Real Trade-off
This is the design question that matters more than any specific syntax. Arrays are tempting because they’re simple to add — no join table, no extra migration — but they come with real costs.
Arrays are a reasonable fit when:
- The “many” side genuinely has no independent identity or attributes of its own — simple tags, a list of category labels, a fixed small set of tracking numbers.
- You rarely need to query, filter, or join against the individual elements independently of their parent row.
- The list is typically small and doesn’t grow unbounded.
- You want denormalized, fast reads without needing referential integrity per element.
A related table (proper one-to-many with a foreign key) is the better fit when:
- The related items have their own attributes (an order item needs a price and quantity, not just a product ID).
- You need referential integrity — foreign keys, cascading deletes, uniqueness constraints on the related data.
- The list can grow large or unbounded (thousands of related rows per parent).
- You frequently need to query, aggregate, or join on the individual related items as first-class data.
A very common mistake is reaching for an array where a proper join table was actually needed — usually surfacing later as pain around enforcing uniqueness within the array, needing to store metadata per element, or needing efficient queries that treat elements as independent rows. If you find yourself calling unnest() constantly just to run normal relational queries, that’s a sign the data probably belongs in its own table.
Practical Use Cases
1. Tagging Systems
SELECT product_name FROM products WHERE tags @> ARRAY['sale', 'featured'];
2. Storing Small, Fixed Sets of Related Scalars
Phone numbers for a contact, a short list of alternate email addresses, allowed IP addresses for a service account — cases where the “many” items are simple scalars without their own attributes.
3. Denormalized Read-Optimized Summaries
Using array_agg() to precompute a compact summary (like an order history list) that’s read far more often than it changes.
4. Permission or Role Lists
CREATE TABLE api_keys (
key_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scopes text[] NOT NULL DEFAULT '{}'
);
SELECT * FROM api_keys WHERE scopes @> ARRAY['read:orders'];
Troubleshooting Common Issues
Off-by-one errors from assuming 0-indexing. Postgres arrays are 1-indexed. This catches almost every developer coming from another language at least once.
Slow containment queries. Almost always a missing GIN index. Verify with EXPLAIN ANALYZE that the query planner is actually using it.
NULL vs. empty array confusion. array_length(ARRAY[]::text[], 1) returns NULL, not 0, because an empty array has no first dimension to measure. If you’re checking for “no elements,” use cardinality(arr) = 0 or arr = '{}' rather than assuming array_length returns zero.
Difficulty enforcing uniqueness within an array. There’s no built-in “unique array elements” constraint. You’d need a CHECK constraint comparing the array to a deduplicated version of itself, or better, reconsider whether this data belongs in a related table with a proper unique constraint instead.
Unexpected behavior mixing array append and NULL. NULL || ARRAY['a'] behaves differently than you might expect depending on context — always initialize array columns with a DEFAULT '{}' rather than leaving them nullable if you plan to append to them regularly, to avoid null-related surprises.
Best Practices
- Default array columns to
'{}'rather than leaving them nullable, unless a genuinely meaningful distinction exists between “no array” and “empty array” in your domain. - Add a GIN index any time you’re filtering with
@>,<@, or&&against a table of meaningful size. - Use
unnest()freely for read queries that need to treat elements relationally — but if you’re doing that constantly, reconsider whether a proper table would serve you better long-term. - Remember 1-based indexing explicitly in code reviews and documentation — it’s a near-universal source of subtle bugs.
- Reserve arrays for genuinely simple, attribute-less, bounded lists — and reach for a related table the moment the “many” side needs its own identity or attributes.
Arrays of Composite Types and Other Complex Types
Arrays aren’t limited to simple scalar types — they can hold arrays of composite types, arrays of custom domains, and even arrays of arrays, which opens up some genuinely useful modeling patterns for tightly bundled, list-shaped data.
CREATE TYPE price_point AS (
region text,
price numeric(10,2)
);
CREATE TABLE products (
product_id serial PRIMARY KEY,
product_name text,
regional_prices price_point[]
);
INSERT INTO products (product_name, regional_prices)
VALUES (
'Premium Widget',
ARRAY[ROW('US', 29.99)::price_point, ROW('EU', 27.50)::price_point]
);
SELECT product_name, (unnest(regional_prices)).region, (unnest(regional_prices)).price
FROM products;
This pattern is genuinely convenient for small, bounded, tightly-coupled lists of structured data — but the same caution from earlier applies even more strongly here: if regional prices need their own update history, independent uniqueness constraints, or frequent independent querying, a proper product_regional_prices table with a foreign key is almost certainly the better long-term choice.
Array Functions for Set-Like Operations
Beyond the containment operators covered earlier, PostgreSQL offers functions for treating arrays more like mathematical sets, which is useful for deduplication and comparison logic:
SELECT array(SELECT DISTINCT unnest(ARRAY[1,2,2,3,3,3]));
-- {1,2,3} (deduplicated)
SELECT array(SELECT unnest(ARRAY['a','b','c']) INTERSECT SELECT unnest(ARRAY['b','c','d']));
-- {b,c} (set intersection via unnest + INTERSECT)
SELECT array(SELECT unnest(ARRAY['a','b','c']) EXCEPT SELECT unnest(ARRAY['b']));
-- {a,c} (set difference)
These patterns — unnesting into a subquery, applying a set operation, and re-aggregating with array() — cover most of the “set algebra on arrays” needs that don’t have a single dedicated built-in function, and they read reasonably clearly once you’re used to the pattern.
Arrays as Function Parameters
Passing arrays into functions is a common and genuinely clean way to handle “match against any of these values” logic without dynamically building SQL strings:
CREATE OR REPLACE FUNCTION products_with_any_tag(p_tags text[])
RETURNS SETOF products AS $$
BEGIN
RETURN QUERY
SELECT * FROM products WHERE tags && p_tags;
END;
$$ LANGUAGE plpgsql;
SELECT * FROM products_with_any_tag(ARRAY['sale', 'featured']);
This is considerably cleaner than the common but fragile alternative of dynamically building an IN (...) clause by string concatenation, and it plays well with parameterized queries from application code, since most database drivers can bind an array parameter directly without any manual serialization.
Practical Migration: Array to Related Table
Since the arrays-vs-related-table decision comes up so often, it’s worth showing the actual migration path, since teams frequently start with an array for simplicity and outgrow it as requirements evolve:
-- Starting point: tags stored as an array
-- products(product_id, product_name, tags text[])
-- Step 1: create the new normalized table
CREATE TABLE product_tags (
product_id integer REFERENCES products(product_id),
tag text NOT NULL,
PRIMARY KEY (product_id, tag)
);
-- Step 2: migrate existing array data into the new table
INSERT INTO product_tags (product_id, tag)
SELECT product_id, unnest(tags)
FROM products;
-- Step 3: once application code is updated to query the new table,
-- drop the old array column
ALTER TABLE products DROP COLUMN tags;
Knowing this migration path in advance takes a lot of the pressure off the initial “array or table” decision — starting with an array for a genuinely simple case isn’t a permanent commitment, and unnest() makes the eventual migration to a proper table straightforward if requirements grow.
Sorting and Ordering Array Contents
Sometimes you need the elements inside an array itself sorted, independent of how rows are ordered in your result set. Postgres doesn’t have a single built-in “sort array” function, but the unnest-and-reaggregate pattern handles it cleanly:
SELECT array(SELECT unnest(tags) ORDER BY 1)
FROM products
WHERE product_id = 1;
This is worth knowing because it comes up more often than expected — comparing two arrays for “same elements regardless of order,” normalizing array storage so equal sets always produce byte-identical array values, or simply presenting a sorted list to a user, all rely on this same basic pattern of unnesting, sorting, and reaggregating.
A Note on Array Column Growth Over Time
One practical operational concern worth flagging: array columns that grow unbounded over the lifetime of a row (a constantly appended activity log, an ever-growing history list) can eventually cause real problems, since PostgreSQL stores the entire array value inline (or via TOAST for very large values) and rewrites the whole array on every update, even if you’re only appending a single new element. For a small, bounded array like a handful of tags, this is a complete non-issue. For something that could plausibly grow to hundreds or thousands of elements over a row’s lifetime, that’s a strong signal the data belongs in a proper related table instead, where appending a new row is cheap regardless of how many related rows already exist — one of the clearest practical illustrations of the arrays-vs-related-table trade-off discussed earlier in this article.
Quick Reference for Array Construction from Query Results
Beyond array_agg(), the array(subquery) constructor is a handy shorthand for turning any single-column query result directly into an array value:
SELECT array(SELECT product_name FROM products WHERE category = 'electronics' ORDER BY product_name);
This is functionally similar to array_agg() used with a SELECT ... FROM (subquery), but reads more directly for simple cases where you’re not otherwise grouping or aggregating alongside other columns.
Wrapping Up
Array columns are one of PostgreSQL’s most convenient features for avoiding unnecessary join tables when the data genuinely fits — small, simple, attribute-less lists like tags or scopes. With a proper GIN index and the dedicated containment operators, they’re genuinely fast and expressive to query. But they’re not a universal substitute for normalized relational design, and the moment your “list” needs its own attributes, uniqueness rules, or unbounded growth, a real related table is almost always the better long-term choice. Used deliberately, arrays are a genuinely useful tool — not a shortcut around database design.