A column doesn't have to hold a single scalar. Postgres lets any type become an array — text[], int[], timestamptz[] — so one row can carry a whole list. It's a natural fit for tags, and it comes with a rich set of operators for asking "does this list contain that?".
The seed is a tiny blog: a handful of articles, each with a tags text array that overlaps with the others.
SELECT id, title, tags FROM articles ORDER BY id;
Two ways to write an array literal
You'll see arrays written two different ways, and they mean the same thing. The ARRAY[...] constructor takes a comma-separated list of expressions in square brackets. The other form is a single string in curly braces — Postgres parses it into an array based on the column's type:
SELECT ARRAY['postgres', 'arrays'] AS built,
'{postgres,arrays}'::text[] AS from_string;
Both produce the same text[]. The constructor is friendlier when values contain commas, spaces, or quotes; the curly form is compact and is what Postgres shows you on the way out. In the seed you can see both — most rows use ARRAY[...], one uses '{postgres,performance,explain}'.
Indexing is 1-based (and forgiving)
Here's the classic surprise: array indexing starts at 1, not 0. tags[1] is the first element.
SELECT title, tags[1] AS first_tag, tags[2] AS second_tag
FROM articles
ORDER BY id;
And there's no such thing as "index out of bounds" — reaching past the end just returns NULL instead of raising an error. The "Hello, world" row has an empty array, so every index is NULL there:
SELECT title, tags[1] AS first_tag, tags[99] AS way_past_end
FROM articles
ORDER BY id;
You can also take a slice with a colon — tags[2:3] returns a new array of elements 2 through 3 (inclusive on both ends):
SELECT title, tags[1:2] AS first_two
FROM articles
ORDER BY id;