Pilotbase running string_to_table with ordinality on PostgreSQL 18: each item's comma-separated tags as one row per tag, with its position and the trimmed, lower-cased tag

Somebody exported products from a shop, a spreadsheet or an old CRM, and one column holds a list: red,blue, green. It loads into a text column without complaint, and then every question about it gets awkward. Which items are blue? How many use each tag? Rename a tag everywhere? Each of those is a string search, and string searches on lists are wrong in small ways that are hard to spot.

The fix is old and boring: a tags table, an item_tags join table, one row per item and tag. This piece is about getting there from the messy column on PostgreSQL 18.6, MySQL 8.4.11 and SQLite 3.54: splitting, trimming, dropping empty items and repeats, keeping the order, and going back to a list when an export needs one. Everything was run on 1,000,000 generated items on 2026-10-29, and the three engines produced exactly the same 3,325,482 item-tag rows.

The data, and what can go wrong in one cell

The test table has 1,000,000 items with up to six tags each from 48 words (colours, materials, sizes like s and xl, sale, vintage). It's messy the way real exports are: 30% of lists have a space after each comma, and a few percent each have an empty item (grey,, red), a trailing comma, a capital (Summer), a repeated tag (cotton,cotton) or spaces around the whole cell. 30,358 items have NULL and 19,859 an empty string.

Six cells cover every case worth testing:

text
id  tags
1   'red,blue, green'     space after a comma
2   ' red,,blue,'         padded cell, empty item, trailing comma
3   'red,Red,red'         the same tag three times
4   ''                    empty string
5   NULL
6   'navy'                one tag, no comma

PostgreSQL: string_to_table with ordinality

PostgreSQL 14 added string_to_table, which returns one row per item. WITH ORDINALITY adds the item's position, so the original order survives:

sql
select t.id, s.pos, s.tag
from t, string_to_table(t.tags, ',') with ordinality as s(tag, pos);

It splits on the comma and nothing else. Item 1 gives red, blue and ' green' with the space; item 2 gives ' red', '', blue, ''; item 3 gives red, Red, red. NULL gives no rows, and so does the empty string, because PostgreSQL treats '' as a list with no items. unnest(string_to_array(tags, ',')) behaves the same and works on older versions.

regexp_split_to_table(tags, '\s*,\s*') looks tempting because the pattern eats the spaces round the commas, but it still left the padded ' red' at the start of item 2, still returned the empty items, and returned one empty row for the empty string. You need trim and an empty check either way, so the plain split is the better start. It was also the fastest: splitting all 1,000,000 lists into 3,363,444 non-empty items took 0.23 s with string_to_table, 0.28 s with unnest(string_to_array) and 0.51 s with the regex.

The cleaned version: trim, lower-case, drop empties, and keep each tag once at its first position:

sql
select t.id, lower(trim(s.tag)) as tag, min(s.pos) as pos
from t, string_to_table(t.tags, ',') with ordinality as s(tag, pos)
where trim(s.tag) <> ''
group by t.id, lower(trim(s.tag))
order by t.id, pos;
Pilotbase result: item 29 'silver,recycled,' split into silver, recycled and an empty third item; item 36 with cotton twice; item 112 'grey,, red, leather' with an empty second item and red trimmed

Positions keep their gaps (item 2 above has red at 1 and blue at 3), which is fine for ordering. If you want 1, 2, 3, number them again with row_number() over (partition by id order by min(pos)).

One thing to watch: a plain comma join drops items that have no tags. If the next step needs every item, for a count of untagged ones say, use left join lateral string_to_table(...) on true.

MySQL: JSON_TABLE over an array you build

MySQL has no split function that returns rows. The usual trick in 8.0 and later is to turn the list into a JSON array by replacing each comma with ",", then read it with JSON_TABLE, which has its own ordinality column:

sql
select t.id, j.pos, lower(trim(j.tag)) as tag
from t
join json_table(
  concat('["', replace(t.tags, ',', '","'), '"]'),
  '$[*]' columns (pos for ordinality, tag varchar(100) path '$')
) j
where trim(j.tag) <> '';

Split results matched PostgreSQL item for item, with one difference: the empty string becomes [""], an array with one empty item, so the trim(j.tag) <> '' filter is not optional here. All 1,000,000 lists split in 1.47 s.

Three things broke it in testing:

A quote or backslash in the data. A tag like say "hi" makes the built text invalid JSON and the whole statement fails, not just that row: ERROR 3141 (22032): Invalid JSON text in argument 1 to function json_table: "Missing a comma or ']' after an array element." Escape backslashes first, then quotes:

sql
concat('["', replace(replace(replace(t.tags, '\\', '\\\\'), '"', '\\"'), ',', '","'), '"]')

An item longer than the column. With tag varchar(20) path '$', a 45-character item came back as NULL, no error and no warning. Add error on error to the column and you get ERROR 3669 (22003): Value is out of range for JSON_TABLE's column 'tag' instead. Size the column for the longest item you could see, or check max(length(...)) first.

Accents and case in the tags table. The default collation, utf8mb4_0900_ai_ci, is accent and case insensitive, so a unique key on tags.name treats cafe and Café as the same tag: ERROR 1062 (23000): Duplicate entry 'Café' for key 'tag_names.PRIMARY'. That's often what you want for tags. If it isn't, give the column a binary or _as_cs collation.

The older ways work but cost more. A recursive CTE peeling off one item at a time with SUBSTRING_INDEX took 2.24 s, and it stops at cte_max_recursion_depth (default 1000): a list of 1,500 items failed with ERROR 3636 (HY000): Recursive query aborted after 1001 iterations until the session setting was raised. JSON_TABLE read the same 1,500 items with no limit. The numbers-table version you find in many old answers (join to 1..100 and take SUBSTRING_INDEX(SUBSTRING_INDEX(tags, ',', n), ',', -1)) gave the right count but took 23.33 s.

SQLite: json_each, or a recursive CTE

SQLite has the same JSON trick with json_each. Its key column is the 0-based array index, so it doubles as the position:

sql
select t.id, j.key + 1 as pos, lower(trim(j.value)) as tag
from t, json_each('["' || replace(t.tags, ',', '","') || '"]') j
where trim(j.value) <> '';

It behaves like MySQL: one empty item for the empty string, nothing for NULL, and malformed JSON for a quote in the data (the same two replace calls fix it). It split the million lists in 0.52 s. A recursive CTE has no JSON to break and no depth limit, at 1.94 s:

sql
with recursive s (id, pos, tag, rest) as (
  select id, 0, null, tags || ',' from t where tags is not null
  union all
  select id, pos + 1, substr(rest, 1, instr(rest, ',') - 1), substr(rest, instr(rest, ',') + 1)
  from s where rest <> ''
)
select id, pos, lower(trim(tag)) as tag from s where pos > 0 and trim(tag) <> '';

Building tags and item_tags

Same two statements on all three engines: distinct cleaned names into tags, then one row per item and tag, with the first position kept and repeats dropped by the group by. On PostgreSQL:

sql
create table tags (
  id int generated always as identity primary key,
  name text not null unique
);
insert into tags (name)
select distinct lower(trim(tag))
from items, string_to_table(tags, ',') s(tag)
where trim(tag) <> '';

create table item_tags (
  item_id bigint not null references items,
  tag_id int not null references tags,
  position smallint not null,
  primary key (item_id, tag_id)
);
insert into item_tags (item_id, tag_id, position)
select i.id, t.id, min(s.pos)
from items i
cross join lateral string_to_table(i.tags, ',') with ordinality s(tag, pos)
join tags t on t.name = lower(trim(s.tag))
group by i.id, t.id;

create index item_tags_tag on item_tags (tag_id, item_id);

The result was the same everywhere: 48 tags, 3,325,482 item-tag rows for 949,783 items (37,962 repeats removed). The (tag_id, item_id) index is the one that answers "which items have this tag"; the primary key answers "which tags does this item have".

text
1,000,000 items -> 3,325,482 rows     PostgreSQL 18.6   MySQL 8.4.11   SQLite 3.54
split only (fastest way)               0.23 s            1.47 s         0.52 s
fill tags                              0.98 s            1.85 s         1.60 s
fill item_tags, keys in place*         16.6 s            1 min 19.8 s   7.4 s
fill item_tags, keys added after       6.8 s             29.6 s         -
item_tags size (table + indexes)       341 MB            177 MB         74 MB
items size                             92 MB             63 MB          40 MB
* PostgreSQL and SQLite: primary key and foreign keys; MySQL: also the (tag_id, item_id) index

The one-statement fill was slow on PostgreSQL and very slow on MySQL, mostly because of the keys. On PostgreSQL the same INSERT into a table with no keys took 4.2 s, the primary key 2.3 s and both foreign keys 0.4 s afterwards. On MySQL, writing the split rows to a staging table (7.7 s), then the grouped insert with only the primary key (12.1 s), the second index (1.5 s) and the foreign keys (8.3 s) came to 29.6 s against 1 min 19.8 s in one go. For a one-off migration of a big table, load first and add the keys after. SQLite, with foreign keys switched on and a WITHOUT ROWID join table, did it all in 7.4 s.

The join table is bigger than the column it replaces, at least on PostgreSQL, where every row carries a 24-byte header: 140 MB of table and 200 MB of indexes against 92 MB for all of items. That's the price of being able to ask questions quickly.

What the join table buys you

Finding items tagged s (the size) is the classic case where the string version goes wrong:

text
items tagged 's'                                     PostgreSQL         MySQL / SQLite
tags like '%s%'                                      469,077            471,295
','||replace(tags,' ','')||',' like '%,s,%'          68,737             -
find_in_set('s', tags)                               -                  54,014 (MySQL)
find_in_set('s', replace(tags, ' ', ''))             -                  69,272 (MySQL)
split and compare, every row                         69,272 (127 ms)    69,272 (SQLite 0.70 s)
item_tags join                                       69,272 (3.5 ms)    69,272 (MySQL 0.00 s, SQLite 1.1 ms)

LIKE '%s%' matched summer, silk and sale alike. The comma-wrapping trick missed the 535 items written S, because LIKE is case-sensitive in PostgreSQL (and case-insensitive in MySQL and SQLite, which is why their LIKE counts differ). MySQL's FIND_IN_SET looked right and quietly missed 15,258 items, each with a space next to the s. The split-on-the-fly query gets the right answer but reads the whole table every time. The join table gave the right answer from the index: 3.5 ms in PostgreSQL as an index-only scan, against 127 ms for the parallel scan of items. Items with two given tags (red and wool, 4,971 items) took 5.5 ms as a self-join on item_tags.

Going back to a list

Exports, APIs and spreadsheets still want the list. Aggregate with an explicit order, or the order is whatever the plan produces:

sql
-- PostgreSQL
select it.item_id, string_agg(t.name, ',' order by it.position) as tags
from item_tags it join tags t on t.id = it.tag_id
group by it.item_id;

-- MySQL
select it.item_id, group_concat(t.name order by it.position separator ',') as tags
from item_tags it join tags t on t.id = it.tag_id
group by it.item_id;

-- SQLite 3.44 or later (ORDER BY inside an aggregate)
select it.item_id, group_concat(t.name, ',' order by it.position) as tags
from item_tags it join tags t on t.id = it.tag_id
group by it.item_id;
Pilotbase result with the original and rebuilt lists side by side: 'silver,recycled,' becomes 'silver,recycled', 'cotton,cotton' becomes one cotton, 'grey,, red, leather' loses the empty item and spaces, 'Summer, men, navy' becomes lower case

Rebuilding all 949,783 lists took 1.03 s on PostgreSQL, 5.45 s on MySQL and 1.02 s on SQLite. On PostgreSQL every rebuilt list equalled the cleaned original (lower-cased, trimmed, no empties or repeats, same order): 0 mismatches out of 949,783.

MySQL cuts GROUP_CONCAT at 1,024 bytes by default (group_concat_max_len). An item with 200 tags, 2,091 characters of list, came back exactly 1,024 characters long, ending mid-word in olour-102,colour-103. A plain SELECT only adds a warning most clients never show: Warning 1260: Row 104 was cut by GROUP_CONCAT(). Writing the same thing into a table in the default strict mode failed outright with ERROR 1260 (HY000), which is the better outcome. Raise it for the session before any export:

sql
set session group_concat_max_len = 1048576;  -- then the same query returned all 2,091 characters

PostgreSQL's string_agg and SQLite's group_concat have no such limit short of the engine's maximum value size.

Clean the file before you load it, too

Most of the trimming and lower-casing above is there because the file was messy. If the list arrives as a CSV, a pass through a cleaner before the load takes care of padded cells, stray capitals and rows that appear twice, and the SQL then only has to deal with the spaces after commas inside a cell. Load the file as it is into a staging table, split into tags and item_tags with the statements above, check the counts (raw items, items after cleaning, repeats removed), and only then drop the old column. Keep it until the export that rebuilds the list matches what you expect.

More on getting a file into the database in A messy CSV into Postgres without surprises: profile, clean, then load and on load speed in Loading a million rows: COPY, multi-row INSERT and executemany, measured.

Run the split on your own table and look at the rows before you build anything from them: Pilotbase connects to PostgreSQL, MySQL, SQLite and more. Free for Windows, macOS and Linux, or Docker on a server.

Get Pilotbase

Tags column full of padded cells and odd capitals? csvCleaner trims spaces, lower-cases a column and removes duplicate rows in your browser before you load the file.

Open csvCleaner

Sources: PostgreSQL 18 documentation: string functions (string_to_table, string_to_array, regexp_split_to_table), table functions and WITH ORDINALITY, aggregate functions (string_agg). MySQL 8.4 reference manual: JSON_TABLE, GROUP_CONCAT and group_concat_max_len, recursive CTEs and cte_max_recursion_depth, FIND_IN_SET. SQLite: json_each, aggregate functions with ORDER BY, WITH RECURSIVE, WITHOUT ROWID tables. All results are from our own run on 2026-10-29: PostgreSQL 18.6, MySQL 8.4.11 and SQLite 3.54.0 with default settings on an Apple M4 Mac mini, 1,000,000 generated items with a fixed seed.