PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 13: Arrays and ranges

Arrays, ranges and generate_series

Level 3: Advanced SQL20 min

Arrays and range types are first-class PostgreSQL types. Arrays suit small, ordered lists such as tags; ranges model intervals with built-in overlap logic and can prevent double-booking with an exclusion constraint.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Query arrays with ANY, @> and && and expand them with unnest
  • Use range and multirange types and their operators
  • Generate series of numbers and dates to fill gaps in reports

conceptThe concept

Arrays: 'eco' = ANY(tags) tests membership, tags @> '{eco,sale}' containment, tags && '{eco,sale}' overlap. unnest(tags) turns an array into rows (WITH ORDINALITY adds the position) and array_agg goes back. Arrays are 1-based. GIN indexes support @>, && and =.

Ranges such as daterange, tstzrange and int4range have inclusive or exclusive bounds ('[2025-01-01,2025-02-01)'). Operators: && overlaps, @> contains, -|- adjacent, * intersection. Since 14, multiranges hold a set of non-overlapping ranges, and range_agg merges ranges.

An exclusion constraint generalises UNIQUE: EXCLUDE USING gist (during WITH &&) forbids any two rows whose ranges overlap. generate_series produces numbers or timestamps and is the standard way to fill missing days in a report.

whyWhy it matters

Overlap checks written by hand (a.start < b.end AND b.start < a.end) are easy to get wrong at the boundaries and impossible to enforce under concurrency without a constraint. Range types and exclusion constraints make the database guarantee it.

Use caseReal-world scenario

The warehouse schedules maintenance windows that must never overlap, and the sales report must show every day of the month, including days with zero orders.

Try itExamples

Run and edit these in the interactive lesson.

Array membership, containment and overlap
SELECT name, tags,
       'eco' = ANY (tags) AS is_eco,
       tags @> '{eco,sale}' AS eco_and_sale,
       tags && '{limited,new}' AS limited_or_new,
       cardinality(tags) AS n
FROM products
WHERE cardinality(tags) >= 2
ORDER BY id
LIMIT 6;
unnest and array_agg
SELECT tag, count(*) AS products, round(avg(price), 2) AS avg_price
FROM products, unnest(tags) AS tag
GROUP BY tag
ORDER BY products DESC;

SELECT category, array_agg(DISTINCT t ORDER BY t) AS tags_used
FROM products, unnest(tags) t
GROUP BY category
ORDER BY category;
Ranges and multiranges
SELECT daterange('2025-01-01', '2025-02-01') AS january,
       daterange('2025-01-01', '2025-02-01') @> date '2025-01-31' AS has_jan_31,
       daterange('2025-01-01', '2025-02-01') && daterange('2025-01-31', '2025-03-01') AS overlaps,
       daterange('2025-01-01', '2025-02-01') -|- daterange('2025-02-01', '2025-03-01') AS adjacent,
       range_agg(r) AS merged
FROM (VALUES (int4range(1, 5)), (int4range(3, 8)), (int4range(10, 12))) v(r);
Exclusion constraint: no overlapping windows
BEGIN;
CREATE TABLE maintenance (
  id      int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  reason  text NOT NULL,
  during  tstzrange NOT NULL,
  EXCLUDE USING gist (during WITH &&)
);
INSERT INTO maintenance (reason, during) VALUES ('disk swap', '[2025-11-01 22:00, 2025-11-02 02:00)');
INSERT INTO maintenance (reason, during) VALUES ('kernel patch', '[2025-11-02 01:00, 2025-11-02 03:00)');
The second window overlaps the first, so the insert is rejected with a conflicting key value error, and the console rolls the transaction back.
Fill missing days with generate_series
SELECT d::date AS day, count(o.id) AS orders
FROM generate_series(timestamptz '2025-12-20', timestamptz '2025-12-31', interval '1 day') d
LEFT JOIN orders o ON o.created_at >= d AND o.created_at < d + interval '1 day'
GROUP BY d
ORDER BY d;

internalsUnder the hood

Arrays are stored inline (or TOASTed when large) and updating one element rewrites the whole array, so very large or frequently appended arrays become slow; model those as a child table. Exclusion constraints are enforced through a GiST index and check concurrent inserts just as UNIQUE does.

Avoid / preferDo this, not that

Avoid
SELECT name FROM products WHERE tags::text LIKE '%sale%';
Prefer
SELECT name FROM products WHERE tags @> ARRAY['sale'];

Text matching also hits 'wholesale' and cannot use an index. Containment is exact and can use a GIN index.

Avoid
CREATE TABLE bookings (room int, starts timestamptz, ends timestamptz);
Prefer
CREATE TABLE bookings (
  room int, during tstzrange,
  EXCLUDE USING gist (room WITH =, during WITH &&)
);

Two columns cannot stop overlapping bookings under concurrency. A range with an exclusion constraint makes overlaps impossible. (= on an integer inside a GiST constraint needs btree_gist.)

PitfallsCommon mistakes

  • Writing tags = '{eco}' to test membership. That compares whole arrays; use 'eco' = ANY(tags) or tags @> '{eco}'.
  • Storing unbounded, growing lists (all order ids of a customer) in an array. Use a child table.
  • Using closed upper bounds for time ranges. Half-open [start, end) ranges tile perfectly with no overlaps or gaps.

LimitsWhen not to use it

Do not use an array of ids as a substitute for a junction table when the ids reference another table: arrays cannot have foreign keys, and joins through unnest are clumsy and slow at scale.

SecuritySecurity notes

Pass lists from the application as one array parameter (WHERE id = ANY($1)) instead of building IN (1, 2, 3) strings; it is injection-safe and keeps one prepared plan.

ChallengeTry it yourself

List products tagged both eco and sale, with their tags.

Show one solution
One solution
SELECT name, tags
FROM products
WHERE tags @> ARRAY['eco', 'sale']
ORDER BY name;

InterviewQuestions you should be able to answer

How would you prevent double-booking a resource in PostgreSQL?
Store the booking period as a range (tstzrange) and add an exclusion constraint: EXCLUDE USING gist (room_id WITH =, during WITH &&) (the equality part on a scalar column needs the btree_gist extension). The database then rejects any overlapping booking for the same room, even from concurrent transactions, which application-side checks cannot guarantee without locking.
When would you use an array column instead of a child table?
For small, bounded lists that are read and written as a unit, such as tags or a few labels, especially with a GIN index for @> and &&. Use a child table when the list is large or unbounded, when elements need their own attributes or foreign keys, or when elements are added and removed individually and often.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation