---
title: "PostgreSQL Full-Text Search Drops Text in Angle Brackets (ts_headline, to_tsvector)"
canonical: "https://techwiki.co.uk/databases/postgresql-full-text-search-drops-angle-bracket-text"
author: "Adam Birds"
article_type: "documentation"
published: "2026-10-24T23:28:07.924Z"
updated: "2026-10-24T23:28:07.924Z"
categories: ["Databases"]
tags: ["postgresql"]
---**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`.**

```sql
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:

```sql
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:

```sql
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:

```sql
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:

```sql
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:

```sql
SELECT ts_headline('english', replace(body, '<', '‹'), query,
                   'StartSel=**, StopSel=**') AS snippet
FROM docs, websearch_to_tsquery('english', 'slug') AS query
WHERE search @@ query;
```

```python
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:

```sql
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.
