← Rails & Ruby Frameworks

ActionText's Hidden PostgreSQL Performance Tax: TOAST Bloat, N+1 Queries, and the Table That Grows Forever

You called has_rich_text :body. PostgreSQL received a polymorphic table, a TOAST overflow, and a query multiplier. Allow me to itemize.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 24 min read
The rich text was found across 14,208 TOAST chunks. Reassembly is underway.

Good afternoon. Your rich text has been hiding something.

Rails ActionText is a genuinely pleasant feature. You declare has_rich_text :body in your model, drop a Trix editor into your form, and rich content works. Bold, italics, headings, embedded images. No third-party gems. No JavaScript configuration. It is the kind of Rails magic that makes you glad you chose the framework.

I have no quarrel with the convenience. I have a quarrel with what happens beneath it.

At scale, ActionText becomes a PostgreSQL performance problem with three distinct failure modes that compound each other. I have spent considerable time with action_text_rich_texts tables in production — some modest, some carrying several gigabytes of TOAST data — and the pattern is consistent enough to warrant a thorough accounting.

The three problems:

  1. A single polymorphic table that stores rich text for every model in your application — with index selectivity that degrades as you add models
  2. HTML content that routinely exceeds PostgreSQL's ~2KB TOAST threshold, causing 2-10x read slowdown from chunk reassembly and decompression
  3. Each has_rich_text declaration requiring a separate query, even when eager loaded — a structural N+1 problem baked into the abstraction

No one of these is catastrophic alone. Together, they create a performance tax that grows with your data, your model count, and your content length — the three things a successful Rails application accumulates most reliably.

I should note, before we proceed, that this is not an argument against ActionText. It is an argument for understanding what ActionText asks of PostgreSQL. The feature provides genuine value — rich text editing with zero configuration is not trivial to build. But the cost is real, and it arrives in your database logs rather than your application code. A bill that arrives silently is still a bill.

How ActionText stores everything in one table

When you run rails action_text:install, it creates a single table called action_text_rich_texts. Every has_rich_text declaration in every model writes to this table. Your article body. Your product description. Your comment content. Your email template. All of them.

Rails models
# A typical Rails model with ActionText:
class Article < ApplicationRecord
  has_rich_text :body
  has_rich_text :summary
  has_rich_text :internal_notes
end

class Product < ApplicationRecord
  has_rich_text :description
  has_rich_text :specifications
end

class Comment < ApplicationRecord
  has_rich_text :content
end

# Six has_rich_text declarations across three models.
# All six store their HTML in a single table.
The resulting PostgreSQL table
-- ActionText creates ONE table for all rich text in your application:
CREATE TABLE action_text_rich_texts (
  id          bigserial PRIMARY KEY,
  name        varchar   NOT NULL,  -- 'body', 'summary', 'description', etc.
  body        text,                -- The actual HTML content
  record_type varchar   NOT NULL,  -- 'Article', 'Product', 'Comment'
  record_id   bigint    NOT NULL,  -- ID in that table
  created_at  timestamp NOT NULL,
  updated_at  timestamp NOT NULL
);

-- Rails adds this index:
CREATE UNIQUE INDEX index_action_text_rich_texts_uniqueness
  ON action_text_rich_texts (record_type, record_id, name);

-- This is a polymorphic table. record_type + record_id point
-- to any model in your application. No foreign key constraint
-- is possible. No foreign key constraint exists.

This is a polymorphic association. The record_type and record_id columns together point to any model in your application. PostgreSQL cannot enforce a foreign key constraint on columns that reference multiple tables, so there is no referential integrity. A deleted Article leaves its rich text rows behind, pointing at nothing.

The architectural decision is understandable — ActionText needs to work with any model without requiring schema changes per model. One migration, one table, infinite flexibility. It is the same trade-off that polymorphic: true makes throughout Rails, and the same trade-off that PostgreSQL pays for in every application that uses it.

But action_text_rich_texts is a particularly consequential instance of this trade-off, for a reason the schema does not make obvious: the body column contains large text. Not IDs, not short strings, not numeric values — full HTML documents. This single detail transforms the table from an ordinary polymorphic association into a TOAST-heavy storage layer with fundamentally different performance characteristics than the rest of your schema.

Problem one: N+1 queries, per field, by design

ActionText uses lazy loading by default. Accessing article.body fires a query. Accessing article.summary fires another. For a collection of articles, this is the N+1 problem multiplied by the number of rich text fields.

The N+1 pattern
# Loading 25 articles for a listing page:
articles = Article.all.limit(25)

# In your view:
articles.each do |article|
  article.body   # Query 1: SELECT * FROM action_text_rich_texts
                 #   WHERE record_type = 'Article'
                 #   AND record_id = 7 AND name = 'body'

  article.summary # Query 2: SELECT * FROM action_text_rich_texts
                  #   WHERE record_type = 'Article'
                  #   AND record_id = 7 AND name = 'summary'
end

# 25 articles x 2 rich text fields = 50 queries.
# Plus the original SELECT for the articles = 51 queries.
# Add internal_notes and you are at 76.

Seventy-six queries for a page that displays 25 articles. Each query hits the same polymorphic table, each filtered by a different record_id, each potentially decompressing TOAST chunks. This is not hypothetical — it is the default behavior of any view that iterates over a collection and accesses rich text fields.

Rails provides eager loading methods — with_rich_text_body, with_rich_text_summary — and you should use them. But there is a subtlety that is easy to miss.

Eager loading (better, not great)
# The fix: eager load with with_rich_text_*
articles = Article.with_rich_text_body.with_rich_text_summary.limit(25)

# This generates:
# 1. SELECT * FROM articles LIMIT 25
# 2. SELECT * FROM action_text_rich_texts
#      WHERE record_type = 'Article'
#      AND record_id IN (1, 2, 3, ..., 25)
#      AND name = 'body'
# 3. SELECT * FROM action_text_rich_texts
#      WHERE record_type = 'Article'
#      AND record_id IN (1, 2, 3, ..., 25)
#      AND name = 'summary'

# Better — 3 queries instead of 51.
# But notice: each has_rich_text field is STILL a separate query.
# Three fields = four queries. Five fields = six queries.
# There is no with_all_rich_texts method.

Even with eager loading, each field is a separate query. There is no with_all_rich_texts that loads every rich text field in one round trip. Three fields means four queries (one for the articles, one per field). Five fields means six queries. This is not a bug — it is how ActionText's eager loading is implemented. Each field name becomes a separate scope because the polymorphic index is structured around the (record_type, record_id, name) triple.

Why with_all_rich_texts does not exist

You might reasonably ask: why not combine all the field queries into a single WHERE name IN ('body', 'summary', 'internal_notes')? The answer is architectural, not technical.

The missing method
# Why doesn't ActionText provide with_all_rich_texts?
# Let's look at what it would need to do.

# Current approach — one query per field:
SELECT * FROM action_text_rich_texts
  WHERE record_type = 'Article'
  AND record_id IN (1, 2, ..., 25)
  AND name = 'body';

SELECT * FROM action_text_rich_texts
  WHERE record_type = 'Article'
  AND record_id IN (1, 2, ..., 25)
  AND name = 'summary';

# Hypothetical combined approach:
SELECT * FROM action_text_rich_texts
  WHERE record_type = 'Article'
  AND record_id IN (1, 2, ..., 25)
  AND name IN ('body', 'summary', 'internal_notes');

# This single query returns all rich text for all articles.
# But Rails would need to:
#   1. Know which fields exist on the model (it does)
#   2. Group the results by (record_id, name) in Ruby
#   3. Assign each result to the correct association
#
# This is architecturally possible. It simply was not built.
# ActionText models each field as a separate has_one association,
# and Active Record eager loads has_one associations individually.

ActionText models each rich text field as a separate has_one association through the ActionText::RichText model. Active Record eager loads has_one associations individually — one query per association. This is the same pattern you see with includes(:profile, :avatar, :settings): three associations, three queries, even though they could theoretically be combined.

I mention this not to criticize the implementation, but to clarify the constraint. The per-field query cost is structural. You cannot optimize it away within ActionText's architecture. You can only mitigate it by ensuring eager loading is present on every view that accesses rich text collections.

The cost table makes the arithmetic explicit:

OperationQueriesTOAST costNotes
Load one rich text field1 SELECT1-10 chunk reads per rowLazy by default, triggers on access
Load N articles with 1 field1 + 1 = 2N x chunk readsUsing with_rich_text_body
Load N articles with 3 fields1 + 3 = 43N x chunk readsEach field is a separate query
Load N articles, no eager load1 + 3N3N x chunk readsClassic N+1 pattern
Load N with 3 fields + embeds1 + 6 = 73N x chunk reads + blob I/Owith_rich_text_*_and_embeds
Save an article with rich text1 INSERT + 1 INSERT per fieldTOAST on write (compression)Each field is a separate INSERT
Update rich text content1 UPDATE per field changedFull TOAST rewriteNo partial TOAST updates — entire body replaced

The "1 + 3N" row is the one that matters most. If you forget a single with_rich_text_* call on a listing page, your 25-item page generates 76 queries. At 100 requests per minute, that is 7,600 queries per minute from one view. The per-field eager load row is better — four queries instead of 76 — but the TOAST cost remains identical. Every one of those rows must still be decompressed and reassembled.

Problem two: TOAST bloat on every read

Rich text is, by definition, text that is not small. A blog article body routinely contains 5-50KB of HTML. A product description with formatting and embedded attributes hits 3-10KB easily. PostgreSQL's TOAST mechanism activates at approximately 2KB — any value exceeding that threshold gets compressed, sliced into chunks, and stored in a separate TOAST table.

This is not a flaw in PostgreSQL. TOAST is an elegant solution to the problem of storing large values in a fixed-size page structure. The acronym itself — The Oversized-Attribute Storage Technique — is one of the more charming names in database engineering. But it has a cost that matters enormously for a table where the primary column — body — almost always exceeds the threshold.

Measuring TOAST impact
-- PostgreSQL TOAST (The Oversized-Attribute Storage Technique)
-- stores values exceeding ~2KB in a separate TOAST table.

-- Check if your rich text content exceeds the TOAST threshold:
SELECT
  name,
  record_type,
  avg(octet_length(body)) AS avg_bytes,
  max(octet_length(body)) AS max_bytes,
  count(*) FILTER (WHERE octet_length(body) > 2048) AS over_toast_threshold,
  count(*) AS total_rows,
  round(100.0 * count(*) FILTER (WHERE octet_length(body) > 2048)
    / count(*), 1) AS pct_toasted
FROM action_text_rich_texts
GROUP BY name, record_type
ORDER BY avg_bytes DESC;

-- Typical results for a content-heavy app:
--  name   | record_type | avg_bytes | max_bytes | over_toast | total  | pct
-- --------+-------------+-----------+-----------+------------+--------+-----
--  body   | Article     |     12847 |    187204 |       4821 |   5000 | 96.4
--  desc   | Product     |      3291 |     41088 |       1872 |   3000 | 62.4
--  summary| Article     |       847 |      4096 |        312 |   5000 |  6.2
--  content| Comment     |       218 |      2891 |         47 |  50000 |  0.1

In a typical content application, 60-95% of the body values in action_text_rich_texts exceed the TOAST threshold. Every read of those values requires PostgreSQL to:

  1. Read the main tuple and find a TOAST pointer instead of inline data
  2. Look up the TOAST table's index to locate the chunks
  3. Read multiple chunks (each ~2KB) from the TOAST table
  4. Decompress the chunks (TOAST uses pglz or lz4 compression)
  5. Reassemble the full value and return it

For a 12KB article body, that is roughly 6 chunk reads plus an index lookup plus decompression. Multiply that by every row in a sequential scan or a batch eager load, and the numbers become significant.

The TOAST read penalty
-- TOAST imposes a measurable penalty on every read.
-- The main table stores a pointer; the actual data lives in
-- a separate TOAST table (pg_toast.pg_toast_NNNNN).

-- Reading a TOASTed value requires:
-- 1. Read the main tuple (finds a TOAST pointer, not inline data)
-- 2. Look up the TOAST table's index to find the chunks
-- 3. Read 1 or more chunks (each up to ~2KB) from the TOAST table
-- 4. Decompress (TOAST uses pglz or lz4 compression)
-- 5. Reassemble and return

-- For a 12KB article body:
--   6 TOAST chunks read + 1 index lookup + decompression
--   vs. a single inline read for a 500-byte comment

-- Benchmark: sequential scan over 5,000 articles
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, body FROM action_text_rich_texts
WHERE record_type = 'Article' AND name = 'body';

-- With inline storage (small values, hypothetical):
--   Seq Scan  (actual time=0.041..18.7 rows=5000)
--   Buffers: shared hit=892

-- With TOASTed storage (typical 5-50KB HTML):
--   Seq Scan  (actual time=0.052..147.3 rows=5000)
--   Buffers: shared hit=892, shared read=14208 (TOAST reads)

-- 7.8x slower. Same query. Same row count.
-- The difference is entirely TOAST decompression and chunk I/O.

7.8x slower on a warm cache. The row count is identical. The query is identical. The only difference is whether the body column fits inline (small values) or requires TOAST chunk reassembly (typical HTML content). On a cold cache — which is common for infrequently accessed content — the multiplier can reach 10x or higher because the TOAST chunks are not in the buffer cache.

The internal mechanics of TOAST retrieval

To understand why this penalty exists, it helps to examine what PostgreSQL actually does when it encounters a TOASTed value.

TOAST internals
-- How TOAST stores and retrieves data internally:

-- Step 1: Check the TOAST storage strategy for the body column
SELECT attname, attstorage
FROM pg_attribute
WHERE attrelid = 'action_text_rich_texts'::regclass
  AND attname = 'body';

--  attname | attstorage
-- ---------+-----------
--  body    | x

-- 'x' means EXTENDED: compress first, then store out-of-line if still large.
-- This is the default for text columns.

-- Step 2: Inspect actual TOAST table size vs main table
SELECT
  pg_size_pretty(pg_relation_size('action_text_rich_texts')) AS main_table,
  pg_size_pretty(
    pg_relation_size(
      (SELECT reltoastrelid FROM pg_class
       WHERE relname = 'action_text_rich_texts')
    )
  ) AS toast_table;

-- Typical result:
--  main_table | toast_table
-- -----------+-------------
--  147 MB     | 2.8 GB

-- The TOAST table is 19x the main table.
-- Every SELECT that touches the body column traverses both.

The body column uses the EXTENDED storage strategy by default, meaning PostgreSQL first attempts to compress the value with pglz (or lz4 in PostgreSQL 14+), then stores it out-of-line if it is still too large after compression. HTML compresses well — roughly 3:1 — which means a 12KB article body becomes approximately 4KB on disk, split into two or three TOAST chunks. This is good for storage. It is less good for read latency, because decompression happens on every access, not once.

There is no TOAST result cache. PostgreSQL does not cache decompressed values. If you read the same article body ten times, PostgreSQL decompresses it ten times. The buffer cache stores compressed TOAST chunks, but the decompression cost is paid fresh on every query.

Avoiding TOAST when you do not need the body

One mitigation is surprisingly simple: do not SELECT * when you do not need the body content.

Column projection to avoid TOAST
-- If you only need metadata, don't SELECT the body column.
-- TOAST chunks are only read when the TOASTed column is accessed.

-- Bad: reads TOAST data for every row
SELECT * FROM action_text_rich_texts
WHERE record_type = 'Article';

-- Good: skips TOAST entirely
SELECT id, record_type, record_id, name, updated_at
FROM action_text_rich_texts
WHERE record_type = 'Article';

-- The second query is 7-10x faster because PostgreSQL never
-- touches the TOAST table. The body column's TOAST pointer
-- sits in the main tuple, but PostgreSQL only follows it
-- when the column is actually projected.

-- This matters for admin dashboards, content listings,
-- and anywhere you show metadata without the full body.
-- ActionText always SELECTs *, which always reads TOAST.

PostgreSQL only follows TOAST pointers for columns that are actually projected in the query result. If you SELECT id, record_type, name, updated_at — omitting the body column — the TOAST table is never touched. The query runs 7-10x faster because it reads only the main tuple, which contains the small metadata columns inline.

The difficulty is that ActionText's generated queries always SELECT *. The eager loading methods do not support column selection. If you need to avoid TOAST for admin dashboards, content listings, or search results, you must bypass ActionText and query action_text_rich_texts directly — which negates much of the abstraction's convenience.

Problem three: the table that grows forever

Because every model shares action_text_rich_texts, the table accumulates rows from every feature you build. Comments, articles, products, email templates, forum posts, help desk tickets — anything with has_rich_text contributes rows to the same table.

Table composition after 18 months
-- The table that grows forever: action_text_rich_texts
-- Every model with has_rich_text adds rows here.

-- Check your current table size and composition:
SELECT
  record_type,
  count(*) AS rows,
  pg_size_pretty(sum(octet_length(body))) AS content_size,
  round(100.0 * count(*) / (SELECT count(*) FROM action_text_rich_texts), 1)
    AS pct_of_table
FROM action_text_rich_texts
GROUP BY record_type
ORDER BY count(*) DESC;

-- Typical results after 18 months:
--  record_type      | rows    | content_size | pct_of_table
-- ------------------+---------+--------------+-------------
--  Comment          |  287341 | 84 MB        |       71.2
--  Article          |    5012 | 412 MB       |        1.2
--  EmailTemplate    |    3847 | 28 MB        |        1.0
--  Product          |    2941 | 67 MB        |        0.7
--  ForumPost        |  104283 | 891 MB       |       25.9
-- ------------------+---------+--------------+-------------
--  Total            |  403424 | 1.4 GB       |      100.0

-- Comments are 71% of rows but 6% of content by size.
-- Articles are 1.2% of rows but 29% of content by size.
-- A sequential scan touches all of them regardless.

This creates three problems that reinforce each other.

First, the index gets less selective. The unique index on (record_type, record_id, name) works perfectly for point lookups — finding one specific rich text for one specific record. But any query that scans a range — recent articles, all products updated this week, rich text content matching a search term — must traverse index entries for every type, even when it only cares about one.

Index selectivity at scale
-- The polymorphic index problem:
-- Rails creates this unique index:
--   (record_type, record_id, name)
--
-- This works for point lookups:
--   WHERE record_type = 'Article' AND record_id = 42 AND name = 'body'
--
-- But the index selectivity degrades as types accumulate.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM action_text_rich_texts
WHERE record_type = 'Article' AND name = 'body'
ORDER BY updated_at DESC
LIMIT 20;

-- The unique index covers (record_type, record_id, name).
-- This query filters on record_type and name but NOT record_id.
-- PostgreSQL must scan the index for ALL Article/body entries,
-- then sort by updated_at (which is not in the index).

-- Index Scan using index_action_text_rich_texts_uniqueness
--   (cost=0.42..1847.23 rows=5012 width=8291)
--   (actual time=0.089..42.718 rows=5012 loops=1)
--   Index Cond: ((record_type = 'Article') AND (name = 'body'))
--   Sort  (cost=892.14..904.67 rows=5012 width=8291)
--     Sort Method: top-N heapsort  Memory: 12847kB
-- Buffers: shared hit=4891, shared read=11204

-- 42ms to find 20 recent articles.
-- The sort spills to memory because each row is ~8KB (TOASTed body).
-- A partial index would eliminate the sort entirely.

Second, table bloat from TOAST compounds the vacuum problem. When you update a rich text body, PostgreSQL does not modify the existing TOAST chunks in place. It writes entirely new chunks and marks the old ones as dead. Autovacuum must process both the main table and the TOAST table separately. If autovacuum falls behind on the TOAST table — which stores the vast majority of the data by volume — dead chunks accumulate faster than they are reclaimed.

TOAST table bloat
-- TOAST bloat compounds the vacuum problem.

-- When you UPDATE a rich text body, PostgreSQL:
-- 1. Marks the old TOAST chunks as dead
-- 2. Writes entirely new TOAST chunks (no in-place update)
-- 3. The old chunks remain until VACUUM processes the TOAST table

-- Check TOAST table bloat:
SELECT
  c.relname AS table_name,
  t.relname AS toast_table,
  pg_size_pretty(pg_relation_size(t.oid)) AS toast_size,
  pg_size_pretty(pg_relation_size(c.oid)) AS main_table_size,
  round(100.0 * pg_relation_size(t.oid)
    / GREATEST(pg_relation_size(c.oid), 1), 1) AS toast_ratio_pct
FROM pg_class c
JOIN pg_class t ON t.oid = c.reltoastrelid
WHERE c.relname = 'action_text_rich_texts';

-- Typical result for a content-heavy app:
--  table_name              | toast_table      | toast_size | main_size | ratio
-- -------------------------+------------------+------------+-----------+-------
--  action_text_rich_texts  | pg_toast_16421   | 2.8 GB     | 147 MB    | 1904%

-- The TOAST table is 19x larger than the main table.
-- Autovacuum processes them separately.
-- If autovacuum falls behind on the TOAST table,
-- dead chunks accumulate faster than they are reclaimed.

A TOAST table that is 19x larger than its main table is not unusual for action_text_rich_texts in a content-heavy application. That is 2.8 GB of TOAST data that autovacuum must process, compared to 147 MB for the main table. If your autovacuum settings are tuned for typical tables — where the main relation contains most of the data — they are undertreating the TOAST table by an order of magnitude.

Third, orphaned rows accumulate silently. Because there are no foreign key constraints, deleting a model record does not cascade to its rich text. Rails handles this with dependent: :destroy on the ActionText association — but only if the parent is destroyed through ActiveRecord. A DELETE FROM articles WHERE id = 42 in a migration, a bulk delete, or a rake task leaves the rich text rows behind. Over months, these orphans accumulate.

Detecting orphaned rich text
-- Detecting orphaned rich text rows (no corresponding parent record).
-- Because there are no foreign key constraints, orphans accumulate silently.

-- Step 1: Find orphaned Article rich texts
SELECT art.id, art.record_type, art.record_id, art.name
FROM action_text_rich_texts art
LEFT JOIN articles a ON a.id = art.record_id
WHERE art.record_type = 'Article'
  AND a.id IS NULL;

-- Step 2: Count orphans across all types
SELECT
  record_type,
  count(*) AS orphaned_rows,
  pg_size_pretty(sum(octet_length(body))) AS wasted_storage
FROM action_text_rich_texts art
WHERE NOT EXISTS (
  -- This requires dynamic SQL or per-type checks in practice.
  -- Here is the Article check as an example:
  SELECT 1 FROM articles a WHERE a.id = art.record_id
)
AND record_type = 'Article';

-- Typical finding: 3-8% of rows are orphans after 12+ months.
-- On a 1.4 GB table, that is 40-110 MB of TOAST data
-- that VACUUM must process on every pass, achieving nothing.

Orphaned rows are not merely wasted storage. They are wasted work for every autovacuum cycle, every sequential scan, and every index maintenance operation. Three to eight percent of rows being orphans on a 1.4 GB table means 40-110 MB of data that VACUUM dutifully processes on every pass, achieving nothing. I have seen this number reach 15% in applications that perform bulk deletions without rich text cleanup.

The Active Storage multiplication

ActionText integrates with Active Storage for embedded images and file attachments. When rich text content contains <action-text-attachment> tags — which it does whenever someone drags an image into the Trix editor — loading the rich text triggers additional queries to resolve those attachments.

The attachment query cascade
# ActionText + Active Storage: the hidden attachment queries
# When rich text contains embedded images or files:

class Article < ApplicationRecord
  has_rich_text :body  # may contain <action-text-attachment> tags
end

# Loading article.body triggers:
# 1. SELECT from action_text_rich_texts (the HTML)
# 2. Parse HTML for <action-text-attachment> tags
# 3. For EACH attachment:
#    SELECT from active_storage_attachments (polymorphic again)
#    SELECT from active_storage_blobs (the file metadata)

# An article body with 5 embedded images:
# 1 rich text query + 5 attachment queries + 5 blob queries = 11 queries
# For 25 articles on a listing page: up to 275 queries.

# The eager loading gets complex:
Article
  .with_rich_text_body_and_embeds  # Rails 7+ method
  .limit(25)

# This reduces it to ~4 queries total, but you must
# remember to use the _and_embeds variant. The standard
# with_rich_text_body does NOT eager load attachments.

The standard with_rich_text_body does not eager load attachments. You need with_rich_text_body_and_embeds, a method name long enough to suggest someone was uncomfortable with it. If you use the shorter method, every embedded image triggers two additional queries (one for the attachment record, one for the blob metadata). An article with 5 images viewed on a listing page of 25 articles generates 250 additional queries.

This is not ActionText being careless. It is the natural consequence of layering one polymorphic system (ActionText) on top of another polymorphic system (Active Storage). Each layer adds its own per-record query overhead, and the layers multiply. Polymorphism on top of polymorphism. The database does not see elegant abstractions — it sees a cascade of queries that each touch a different table via a different polymorphic lookup.

The particular difficulty here is remembering which variant to use. If a view that previously displayed plain text articles gains an image feature — a product manager requests inline images in blog posts — every existing with_rich_text_body call silently becomes insufficient. The page still works. It simply generates 250 additional queries that appear nowhere in a code review. The performance regression arrives not when the code changes, but when the first author embeds an image.

Fixing the indexes: partial indexes by type

The default unique index on (record_type, record_id, name) serves point lookups but penalizes range queries. Partial indexes filtered by the type and name values your application actually uses are dramatically more effective.

Partial indexes for ActionText
-- Partial indexes: the escape hatch for polymorphic selectivity

-- Instead of one bloated composite index, create targeted partial indexes:
CREATE INDEX idx_rich_texts_article_body
  ON action_text_rich_texts (record_id, updated_at DESC)
  WHERE record_type = 'Article' AND name = 'body';

CREATE INDEX idx_rich_texts_article_summary
  ON action_text_rich_texts (record_id, updated_at DESC)
  WHERE record_type = 'Article' AND name = 'summary';

CREATE INDEX idx_rich_texts_comment_content
  ON action_text_rich_texts (record_id)
  WHERE record_type = 'Comment' AND name = 'content';

-- Now the same query:
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM action_text_rich_texts
WHERE record_type = 'Article' AND name = 'body'
ORDER BY updated_at DESC
LIMIT 20;

-- Index Scan Backward using idx_rich_texts_article_body
--   (cost=0.29..2.41 rows=20 width=8291)
--   (actual time=0.018..0.097 rows=20 loops=1)
--   Buffers: shared hit=24

-- 0.097ms vs 42ms. Sort eliminated. 430x faster.
-- The partial index contains only Article/body rows — ~5,000 entries
-- instead of 403,000 in the full composite index.

430x faster for a common query pattern. The partial index contains only the rows for the specific (record_type, name) combination, which means it is orders of magnitude smaller than the full composite index. For a table with 400,000 rows across 6 types, each partial index might contain 5,000-50,000 entries instead of 400,000.

The trade-off is management. You need one partial index per (record_type, name) pair you want to optimize. Six has_rich_text declarations across three models means up to six partial indexes. This is exactly the kind of index proliferation that is tedious to manage manually and straightforward to automate.

I should also note that partial indexes do not help with the TOAST cost. The index makes finding the right rows faster, but reading the body column still requires TOAST chunk retrieval. The partial index reduces the 42ms index scan to 0.097ms, but the per-row TOAST decompression on the returned rows remains unchanged. These are two separate problems with two separate solutions.

Tuning autovacuum for the shared table

The default autovacuum settings assume a typical table where the main relation contains the bulk of the data. action_text_rich_texts inverts this assumption — its TOAST table is often 10-20x larger than the main table. The defaults are, quite frankly, inadequate for this scenario.

Autovacuum tuning for ActionText
-- Autovacuum tuning for the ActionText TOAST table.
-- The defaults are calibrated for typical tables where
-- the main relation holds most data. ActionText inverts this.

-- Check current autovacuum stats:
SELECT
  relname,
  n_dead_tup,
  last_autovacuum,
  autovacuum_count,
  pg_size_pretty(pg_total_relation_size(oid)) AS total_size
FROM pg_stat_user_tables
WHERE relname = 'action_text_rich_texts';

-- Tune autovacuum specifically for this table:
ALTER TABLE action_text_rich_texts SET (
  autovacuum_vacuum_scale_factor = 0.01,    -- default 0.2 (20%)
  autovacuum_vacuum_threshold = 100,        -- default 50
  autovacuum_analyze_scale_factor = 0.005,  -- default 0.1 (10%)
  toast.autovacuum_vacuum_scale_factor = 0.005,  -- TOAST-specific
  toast.autovacuum_vacuum_cost_delay = 2         -- more aggressive
);

-- With these settings, autovacuum triggers after 1% of rows change
-- instead of 20%. For a 400,000-row table, that means vacuum runs
-- after ~4,000 changes instead of ~80,000. This keeps TOAST bloat
-- from compounding between vacuum cycles.

The key insight is the toast.autovacuum_vacuum_scale_factor setting. This allows you to tune autovacuum for the TOAST table independently of the main table. Setting it to 0.005 (0.5%) means the TOAST table gets vacuumed after a much smaller percentage of changes, preventing the bloat from compounding between cycles.

Without this tuning, the default 20% scale factor means autovacuum waits until 80,000 TOAST rows change before processing. For a table receiving steady updates — articles being edited, comments being posted — that delay allows gigabytes of dead TOAST chunks to accumulate. I have observed TOAST tables that were 4x their expected size purely from vacuum lag.

The alternative: store rich text on the model

If ActionText's polymorphic architecture is the root cause, one option is to sidestep it entirely. Store the HTML directly on the model's own table.

Direct column storage
# Alternative: store rich text directly on the model
# Instead of ActionText's polymorphic table:

class AddBodyToArticles < ActiveRecord::Migration[7.1]
  def change
    add_column :articles, :body_html, :text
    add_column :articles, :summary_html, :text
  end
end

class Article < ApplicationRecord
  # No has_rich_text — store HTML directly
  # Use Trix editor with a custom form field if needed

  validates :body_html, presence: true
end

# Benefits:
# - No extra queries (body loads with the article)
# - No polymorphic table
# - No TOAST indirection (TOAST still applies, but on your table)
# - Foreign key constraints work normally
# - JOINs work normally
#
# Trade-offs:
# - You lose ActionText's attachment handling
# - You lose has_rich_text convenience methods
# - You manage the Trix integration yourself
# - TOAST still applies for large content, but it is on YOUR table
#   and YOUR autovacuum schedule, not a shared global one

This eliminates the polymorphic table, the extra queries, and the shared autovacuum burden. The content loads with the record — no separate query needed. TOAST still applies for large HTML values, but it applies to your table with your autovacuum settings, not a shared global table competing with every other model's rich text.

The trade-off is real: you lose ActionText's built-in attachment handling, its content sanitization, and its Trix editor integration. For applications where rich text is a core feature — a CMS, a content platform, a knowledge base — the convenience of ActionText may outweigh the performance cost. For applications where rich text is an incidental feature — a description field here, a notes field there — storing HTML directly is often the pragmatic choice.

The honest counterpoint: ActionText earns its keep in some situations

I would be a poor waiter indeed if I catalogued ActionText's costs without acknowledging where it earns its keep. There are situations where the abstraction is worth the overhead.

If your application needs embedded file attachments in rich text — images, PDFs, videos — ActionText's integration with Active Storage handles this cleanly. Building this yourself means implementing drag-and-drop uploads, signed URLs, image resizing, and blob cleanup on deletion. That is substantial work. ActionText provides it for free, and it works well.

If your application has many models with rich text fields and the content is small — comments, notes, short descriptions under 2KB — the TOAST cost is effectively zero because the content stores inline. The polymorphic table adds one extra query per field, which at small scale is negligible. For an application with 10,000 rows in action_text_rich_texts, all of the problems I have described are theoretical. They become practical at 100,000 rows. They become urgent at 1,000,000.

The threshold is not a specific row count. It is the combination of row count, content size, and access pattern. A table with 50,000 rows of small comments and 5,000 rows of large articles has different performance characteristics than a table with 50,000 rows of large forum posts. Know your data before you optimize.

The hybrid approach

The most pragmatic solution for many applications is neither "all ActionText" nor "no ActionText," but a hybrid.

Hybrid: ActionText where needed, direct columns elsewhere
# Hybrid approach: ActionText for complex content,
# direct columns for simple fields.

class Article < ApplicationRecord
  # Body uses ActionText — needs attachment support,
  # Trix editor, image embedding
  has_rich_text :body

  # Summary and meta_description are plain HTML — no images,
  # no attachments, just formatted text. Store directly.
  # column :summary_html, :text
  # column :meta_description, :text
end

class Comment < ApplicationRecord
  # Comments are short. Average 218 bytes. 99.9% under TOAST threshold.
  # ActionText is fine here — the overhead is minimal because
  # the content is small enough to store inline (no TOAST).
  has_rich_text :content
end

class Product < ApplicationRecord
  # Product descriptions are medium-length, rarely have attachments.
  # Direct column avoids the polymorphic lookup entirely.
  # column :description_html, :text
  # column :specifications_html, :text
end

# Result: ActionText only where its features are needed (attachment
# handling), direct columns everywhere else. The polymorphic table
# stays small. TOAST stays manageable. Query count drops.

Use ActionText for models that genuinely need its attachment handling. Use direct columns for everything else. The polymorphic table stays small, TOAST stays manageable, and the query count drops without sacrificing the features you need.

"Rails gives you extraordinary power to express intent in a few lines of Ruby. The discipline is ensuring that PostgreSQL interprets that intent the way you expected — not the way ActiveRecord decided on your behalf."

— from You Don't Need Redis, Chapter 9: Ruby & Rails: The Scenic Route

The diagnostic query: understanding your situation

Before applying any fix, run a diagnostic. The appropriate intervention depends on your specific data — which models contribute the most rows, which fields exceed the TOAST threshold, and which fields actually use embedded attachments.

Full ActionText diagnostic
-- Full diagnostic: run this to understand your ActionText situation

WITH stats AS (
  SELECT
    record_type,
    name,
    count(*) AS row_count,
    avg(octet_length(body))::int AS avg_size,
    max(octet_length(body)) AS max_size,
    count(*) FILTER (WHERE octet_length(body) > 2048) AS toasted_rows,
    count(*) FILTER (WHERE body LIKE '%action-text-attachment%') AS has_attachments
  FROM action_text_rich_texts
  GROUP BY record_type, name
)
SELECT
  record_type,
  name,
  row_count,
  pg_size_pretty(avg_size::bigint) AS avg_size,
  pg_size_pretty(max_size) AS max_size,
  round(100.0 * toasted_rows / row_count, 1) AS pct_toasted,
  has_attachments,
  CASE
    WHEN avg_size < 2048 AND has_attachments = 0
      THEN 'CANDIDATE: direct column'
    WHEN avg_size < 2048 AND has_attachments > 0
      THEN 'KEEP: small but has attachments'
    WHEN avg_size >= 2048 AND has_attachments > 0
      THEN 'KEEP: needs ActionText features'
    ELSE 'EVALUATE: large content, no attachments'
  END AS recommendation
FROM stats
ORDER BY row_count DESC;

-- This tells you:
-- 1. Which (record_type, name) pairs are worth partial indexes
-- 2. Which fields could move to direct columns
-- 3. Where TOAST is creating the most overhead
-- 4. Where Active Storage attachment queries are hiding

This query tells you four things. First, which (record_type, name) pairs are worth partial indexes — any combination appearing in range queries benefits. Second, which fields could move to direct columns — small content without attachments gains nothing from ActionText's abstraction. Third, where TOAST is creating the most overhead — fields with high pct_toasted values are the most expensive to read. Fourth, where Active Storage attachment queries are hiding — fields with embedded attachments need the _and_embeds eager loading variant.

Run this query on your production database. The results will tell you whether ActionText's performance tax is theoretical for your application or whether it is the explanation for the latency you have been investigating.

What Gold Lapel does with ActionText traffic

Gold Lapel sits between your Rails application and PostgreSQL. It sees the ActionText query patterns as they arrive — the polymorphic lookups, the per-field eager loads, the repeated identical queries with different record IDs.

Gold Lapel + ActionText
# What Gold Lapel does with ActionText query traffic:

# 1. PARTIAL INDEX CREATION
#    GL sees repeated queries like:
#      WHERE record_type = 'Article' AND record_id = $1 AND name = 'body'
#    It creates partial indexes filtered by (record_type, name) pairs:
#      CREATE INDEX ON action_text_rich_texts (record_id)
#        WHERE record_type = 'Article' AND name = 'body';

# 2. RESULT CACHING
#    Rich text content rarely changes but is read constantly.
#    GL caches the results of repeated rich text lookups.
#    25 articles x 3 fields = 75 queries?
#    After the first load, those 75 queries hit the cache.

# 3. N+1 DETECTION
#    GL detects the pattern: 25 identical queries with different
#    record_id values in rapid succession. It surfaces this in
#    monitoring so you know which views need with_rich_text_*.

# gem "goldlapel-rails" in your Gemfile. That's it.

Three things happen automatically.

Partial indexes on the polymorphic table. Gold Lapel observes which (record_type, name) combinations appear in your query traffic and creates partial indexes for each one. The 430x improvement shown above — from 42ms to 0.097ms — happens without you writing a migration or knowing that partial indexes exist. The proxy sees the query pattern, identifies the missing index, and creates it.

Result caching for repeated rich text lookups. Rich text content changes infrequently but is read constantly. The same article body is loaded on every page view. Gold Lapel caches these results and serves them from the cache on subsequent requests, bypassing the TOAST decompression entirely. Twenty-five articles on a listing page with three rich text fields each? Seventy-five queries on the first load, zero on every subsequent load until the content changes.

N+1 detection. When Gold Lapel sees 25 identical queries in rapid succession — differing only in the record_id parameter — it flags the pattern. You forgot a with_rich_text_body somewhere. The proxy tells you which query, how often, and how much time it costs. The fix is one line of Ruby, but you have to know the problem exists first.

Add gem "goldlapel-rails" to your Gemfile, bundle, and the queries that ActionText generates receive the same optimization as the queries you write yourself. No middleware to configure. No has_rich_text declarations to change. The polymorphic table remains polymorphic. It just stops being slow.

The accounting is complete. The choices are yours.

ActionText's performance tax is not a reason to avoid ActionText. It is a reason to understand what you are paying and decide whether the convenience is worth the cost for each field in your application.

For small content without attachments: direct columns. No polymorphic table, no extra queries, no shared TOAST overhead. The cost of ActionText's abstraction exceeds its benefit.

For large content with attachments: ActionText with partial indexes, tuned autovacuum, and disciplined eager loading. The features justify the overhead, but the overhead requires active management.

For the query multiplier itself: caching. Rich text is read far more often than it is written. A caching layer — whether Gold Lapel's transparent proxy, Rails fragment caching, or a custom solution — eliminates the repeated TOAST decompression that constitutes the majority of the performance cost.

The diagnostic query is your starting point. The TOAST statistics are your evidence. The partial indexes are your most immediate improvement. And the honest assessment of which fields actually need ActionText's features is, I suspect, the intervention that will yield the greatest return.

Rich text is a fine feature. A single polymorphic table for all of it is a bold architectural choice. Understanding what that choice costs in PostgreSQL is simply good stewardship of the household.

Frequently asked questions

Terms referenced in this article

If the question of caching interests you — and with ActionText's repeated TOAST decompression, it should — I have written a rather thorough treatment of PostgreSQL-native caching as an alternative to Redis. The chapter addresses precisely the kind of read-heavy, write-seldom pattern that ActionText inflicts on your database.