- Learn
- PostgreSQL
- PostgreSQL arrays: syntax, operators, and indexing
PostgreSQL arrays: syntax, operators, and indexing
PostgreSQL arrays are 1-indexed and out-of-bounds access returns NULL rather than an error. Here is the syntax and the traps.
Quick answer
Two literal syntaxes, both valid: ARRAY['a','b'] and '{a,b}'. The ARRAY[] form is clearer and does not need quoting rules memorized.
Declaring array columns
Append [] to any type:
PostgreSQL does not enforce array size or dimensionality. int[3] accepts an array of any length, and int[][] accepts a one-dimensional array. The declared bounds are documentation, not constraints. If you need a limit, use a CHECK:
Arrays are 1-indexed
Two things there. Indexing starts at 1, so [0] is empty. And an out-of-range index returns NULL rather than raising an error.
That second point deserves care. There is no bounds checking, so a loop that walks off the end of an array gets NULLs instead of a failure, and NULLs propagate quietly through arithmetic. If you are indexing by a computed position, check cardinality first.
Slices use a colon and are inclusive at both ends:
array_length vs cardinality
This is the trap that produces real bugs:
array_length on an empty array returns NULL, not 0.
So WHERE array_length(tags, 1) = 0 never matches, and WHERE array_length(tags, 1) > 0 excludes empty arrays and silently behaves oddly in NOT conditions, because NULL is not false.
cardinality() returns 0 for an empty array, which is what you almost always want. Use it.
array_length also needs a dimension argument (1 for a normal array), which is a hint that it was designed for the multidimensional case rather than the common one.
Containment and search operators
These are the operators that make array columns worth having:
| Operator | Meaning |
|---|---|
@> | contains: left contains all elements of right |
<@ | is contained by |
&& | overlaps: they share at least one element |
= ANY(arr) | value is in the array |
<> ALL(arr) | value is in none of the array |
|| | concatenate |
Practical translations:
@> with multiple elements is AND. && is OR. That is the single most useful thing to remember here.
ANY vs @>
= ANY(tags) and tags @> ARRAY[x] both test membership, and read almost identically. Only @> can use a GIN index (demonstrated in the indexing section below), so prefer it on any column you have indexed.
ANY is still the right tool when comparing against a subquery or a parameter list:
That last form is genuinely useful: it lets a driver pass a list as a single bind parameter, instead of building IN (?,?,?) dynamically.
unnest and array_agg
unnest expands an array into rows:
Three rows went in and four came out. Note what is missing: post 3, whose tags is an empty array, produced no rows at all.
That is the unnest gotcha. It behaves like an inner join, so rows with empty or NULL arrays disappear from your result. To keep them, use a lateral left join:
Now post 3 appears with a NULL tag.
array_agg is the inverse, collapsing rows into an array:
array_agg accepts ORDER BY inside the call, which is the only way to guarantee element order. Without it, order is whatever the plan happens to produce.
To deduplicate:
Indexing arrays
A plain B-tree index on an array column is nearly useless: it can only serve whole-array equality. For containment queries you want GIN:
That index accelerates @>, <@, and &&. It is the entire reason array columns are viable at scale.
It does not accelerate = ANY. On a 20,000-row table with a GIN index present:
Same question, same index, and the = ANY form scans the whole table. Rewrite membership tests as @> on indexed columns.
GIN indexes are slower to update than B-trees and larger on disk. For a write-heavy table with a rarely-queried array, the index may cost more than it saves.
Arrays of NULL, and NULL arrays
Three different things, and they are not interchangeable:
NULLarray: the column has no value.{}: an array with zero elements.{NULL}: an array with one element, which is NULL.cardinalityis 1.
Test for them differently:
That last line is worth flagging: containment with NULL does not behave usefully, because NULL is not equal to anything, including itself. Use array_position(tags, NULL) IS NOT NULL to find a NULL element.
A NOT NULL DEFAULT '{}' on array columns avoids most of this class of problem, and is a good default.
Useful functions
| Function | Does |
|---|---|
cardinality(arr) | Number of elements (0 for empty) |
array_length(arr, 1) | Length, or NULL if empty |
array_append(arr, e) / arr || e | Add an element |
array_remove(arr, e) | Remove all matching elements |
array_position(arr, e) | Index of the first match, or NULL |
array_positions(arr, e) | Array of all matching indexes |
array_to_string(arr, ',') | Join to text |
string_to_array('a,b', ',') | Split text to an array |
array_cat(a, b) | Concatenate two arrays |
When not to use an array
Arrays are tempting and frequently the wrong choice. Use a proper join table when:
- The elements are entities, with their own attributes. Tags with descriptions and colours are a table, not a
text[]. - You need foreign keys. PostgreSQL cannot enforce a foreign key on array elements. An
int[]of user ids will happily reference users that no longer exist. - You need to update elements individually and concurrently. Updating one element rewrites the entire array, so two concurrent updates to different elements of the same row conflict.
- The array grows without bound. Rows have a size limit and large arrays get TOASTed, which is slower than a join.
Arrays are a good fit when the elements are simple values, belong wholly to the row, are read together, and are small in number. A list of tags on a post is the canonical good case. A list of order line items is not.
Common problems
array_length returns NULL for my empty arrays. By design. Use cardinality.
Rows disappeared when I used unnest. Empty and NULL arrays produce no rows. Use LEFT JOIN LATERAL ... ON true.
My @> query is slow. No GIN index. Add one.
Out-of-bounds access returns NULL instead of erroring. By design. Check cardinality before indexing by a computed position.
ARRAY['a','b'] = ARRAY['b','a'] is false. Arrays are ordered. For set semantics use @> in both directions, or sort before comparing.
Quick reference
| Task | Syntax |
|---|---|
| Declare | tags text[] |
| Literal | ARRAY['a','b'] or '{a,b}' |
| First element | arr[1] (1-indexed) |
| Slice | arr[2:3] |
| Length | cardinality(arr) |
| Contains element | arr @> ARRAY['x'] |
| Contains all (AND) | arr @> ARRAY['x','y'] |
| Contains any (OR) | arr && ARRAY['x','y'] |
| Membership | 'x' = ANY(arr) |
| Append | arr || 'x' |
| Remove | array_remove(arr, 'x') |
| Expand to rows | unnest(arr) |
| Expand, keeping empties | LEFT JOIN LATERAL unnest(arr) ON true |
| Collapse rows to array | array_agg(x ORDER BY x) |
| Index for containment | CREATE INDEX ... USING GIN (arr) |