Documentation

PostgreSQL Full-Text Search Drops Text in Angle Brackets (ts_headline, to_tsvector)

PostgreSQL's text search parser treats <word> as an HTML tag, dropping it from ts_headline snippets and the index. Escape < before parsing to keep it.

A
Adam Birds
Published 24 October 20262 min read1 views

PostgreSQL full-text search silently drops anything that looks like an HTML tag, such as <slug> or <your-domain>. It disappears from ts_headline snippets, and the words inside it are never indexed by to_tsvector.

SELECT ts_headline('english',
  'Notes live in projects/<slug>/context.md for each project.',
  websearch_to_tsquery('english', 'project'));
-- Notes live in <b>projects</b>/ /context.md for each <b>project</b>.

Placeholders in angle brackets are common in documentation, config examples and paths, so snippets come back with gaps and searches for the bracketed word find nothing.

Cause

The default text search parser recognises <...> as a tag token. Tag tokens are not mapped to any dictionary, so to_tsvector skips them and ts_headline replaces them with a space. ts_debug shows the classification:

SELECT alias, token FROM ts_debug('english', 'projects/<slug>/context.md');
--  alias    | token
-- ----------+-------------
--  asciiword| projects
--  blank    | /
--  tag      | <slug>
--  file     | /context.md

The word inside the brackets is lost from the index too, so a search for slug does not match the row at all:

SELECT to_tsvector('english', 'Path is projects/<slug>/context.md');
-- '/context.md':4 'path':1 'project':3

Fix

Neutralise < before the text reaches the parser, both when indexing and when highlighting. No extension or custom parser is needed.

Index the text without tags

Replace < with a space in the expression that builds the tsvector, so the bracketed word becomes an ordinary token. For a stored generated column:

ALTER TABLE docs
  ADD COLUMN search tsvector GENERATED ALWAYS AS (
    to_tsvector('english', replace(body, '<', ' '))
  ) STORED;

CREATE INDEX docs_search_idx ON docs USING gin (search);

If the column already exists, drop it and add it again with the new expression; existing rows are recomputed when it is added.

Highlight HTML snippets with entities

If snippets are inserted into a web page as HTML, escape & and then < as entities before calling ts_headline. The parser keeps entities as text, so the bracketed word is highlighted and the output is already escaped for use as HTML text content:

SELECT ts_headline('english',
  replace(replace(body, '&', '&amp;'), '<', '&lt;'),
  websearch_to_tsquery('english', 'slug'))
FROM docs;
-- Path is projects/&lt;<b>slug</b>>/context.md

Escape & first, otherwise the &lt; produced for < would be double-escaped.

Highlight plain-text snippets with a lookalike

If snippets are shown as plain text or Markdown (a terminal, a JSON API, an LLM tool result), swap < for a lookalike character such as โ€น (U+2039) and swap it back in the application:

SELECT ts_headline('english', replace(body, '<', 'โ€น'), query,
                   'StartSel=**, StopSel=**') AS snippet
FROM docs, websearch_to_tsquery('english', 'slug') AS query
WHERE search @@ query;
snippet = row["snippet"].replace("โ€น", "<")

Any genuine โ€น in the text will also come back as <, which is rarely a problem.

Verify

The bracketed word should now be indexed:

SELECT to_tsvector('english', replace('Path is projects/<slug>/context.md', '<', ' '));
-- '/context.md':5 'path':1 'project':3 'slug':4

A search for the word should match, and the snippet should show <slug> (or &lt;slug> in the HTML variant) instead of a blank.

Notes

  • Tested on PostgreSQL 18.6.
  • > does not need escaping: without a matching <, the parser does not see a tag.
  • Apply the same replacement to every column that feeds the tsvector (for example titles and headings as well as the body), and to the text passed to ts_headline, or the index and the snippets will disagree.
  • Ranking with ts_rank or ts_rank_cd uses the stored tsvector, so it picks up the fix once the column is rebuilt.
Last updated: 24 October 2026v1