TL;DRPostgreSQL rows must fit inside an 8 KB page. After the header overhead, that leaves exactly 8160 bytes per row. Switch variable-length columns to EXTENDED storage and PostgreSQL handles the rest โ compressing the value and spilling it to a TOAST table automatically:
ALTER TABLE your_table ALTER COLUMN large_column SET STORAGE EXTENDED;
-- Rewrite existing rows so the new strategy takes effect
UPDATE your_table SET large_column = large_column WHERE id IS NOT NULL;
Still hitting the limit? You'll need to normalize the table or pull blobs out of the database entirely.
Why This HappensEvery row in PostgreSQL lives inside an 8 KB page. TOAST (The Oversized-Attribute Storage Technique) is the escape hatch โ when a single column value crosses roughly 2 KB, PostgreSQL compresses it and/or moves it to a separate TOAST table. The catch: TOAST fires per-column, not per-row.
That means a table with 30 columns, each holding 300 bytes of JSON, never triggers TOAST for any individual column. But 30 ร 300 = 9000 bytes โ well over the 8160-byte limit. Every INSERT fails with:
ERROR: row is too big: size 8208, maximum size 8160
The other trap is PLAIN storage. Certain fixed-width array types โ and any column you've explicitly set to PLAIN โ can't be compressed or moved out-of-line at all. Those bytes are non-negotiable.
Diagnose FirstBefore changing anything, find out which columns are blocking TOAST:
SELECT
a.attname AS column_name,
t.typname AS data_type,
CASE a.attstorage
WHEN 'p' THEN 'PLAIN (no TOAST)'
WHEN 'm' THEN 'MAIN (compress inline, TOAST as last resort)'
WHEN 'x' THEN 'EXTENDED (compress + out-of-line)'
WHEN 'e' THEN 'EXTERNAL (out-of-line, no compress)'
END AS storage_strategy
FROM pg_attribute a
JOIN pg_type t ON a.atttypid = t.oid
WHERE a.attrelid = 'your_table'::regclass
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum;
Then measure the damage:
SELECT pg_size_pretty(AVG(pg_column_size(t.*))::bigint) AS avg_row_size
FROM your_table t;
-- Find the biggest offenders
SELECT id, pg_column_size(t.*) AS row_bytes
FROM your_table t
ORDER BY row_bytes DESC
LIMIT 10;
Any variable-length column (text, varchar, jsonb, bytea) showing MAIN or PLAIN storage is where you start.
Fix 1 โ Change Column Storage StrategyOften one ALTER is all it takes. Switch MAIN columns to EXTENDED and PostgreSQL compresses the value first, then moves it out-of-line if it's still too large:
-- For text, varchar, jsonb, xml columns
ALTER TABLE events ALTER COLUMN notes SET STORAGE EXTENDED;
ALTER TABLE events ALTER COLUMN payload SET STORAGE EXTENDED;
-- For bytea where you want out-of-line but no compression overhead
ALTER TABLE events ALTER COLUMN raw_blob SET STORAGE EXTERNAL;
One gotcha: the new strategy only applies to rows written after the ALTER. Existing rows keep their old on-disk format until you rewrite them:
-- Lightweight rewrite (briefly locks each row)
UPDATE events SET notes = notes;
-- Or use pg_repack for zero-downtime rewrite (requires pg_repack extension)
pg_repack -t events -d your_database
Fix 2 โ Consolidate Many Columns into JSONBWide tables are the classic culprit โ 15 to 30 text columns, most of them NULL on any given row. Collapsing the optional ones into a single JSONB column cuts both row width and schema clutter. JSONB defaults to EXTENDED storage, so compression is automatic:
-- Add a JSONB column to hold the overflow fields
ALTER TABLE events ADD COLUMN metadata JSONB;
-- Migrate existing rows
UPDATE events
SET metadata = jsonb_build_object(
'extra_field_1', extra_field_1,
'extra_field_2', extra_field_2,
'extra_field_3', extra_field_3
);
-- Verify, then drop the old columns
ALTER TABLE events
DROP COLUMN extra_field_1,
DROP COLUMN extra_field_2,
DROP COLUMN extra_field_3;
Best suited for fields that don't need individual B-tree indexes and tend to be read together in a single query.
Fix 3 โ Normalize with a 1:1 Child TableWhen the schema is locked โ ORMs, external constraints, a legacy app that owns the DDL โ vertical table splitting is the workaround. Move the bulky, infrequently accessed columns into a child table and join when you need them:
CREATE TABLE events_content (
event_id BIGINT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
description TEXT,
raw_payload BYTEA,
audit_log TEXT
);
INSERT INTO events_content (event_id, description, raw_payload, audit_log)
SELECT id, description, raw_payload, audit_log FROM events;
ALTER TABLE events
DROP COLUMN description,
DROP COLUMN raw_payload,
DROP COLUMN audit_log;
-- Join when you need the full record
SELECT e.*, ec.description, ec.raw_payload
FROM events e
LEFT JOIN events_content ec ON ec.event_id = e.id
WHERE e.id = 123;
Fix 4 โ Move Blobs Out of the DatabaseBinary files โ images, PDFs, ZIP archives โ don't belong in PostgreSQL rows. A single 500 KB PDF blows past the 8160-byte limit on its own. Store files on S3, a CDN, or local disk and keep only the reference in the table:
-- Add a path/URL reference column
ALTER TABLE documents ADD COLUMN file_url TEXT;
-- Application layer: upload file to S3, write the URL
UPDATE documents
SET file_url = 'https://your-bucket.s3.amazonaws.com/docs/doc_123.pdf'
WHERE id = 123;
-- Drop the inline blob column once migrated
ALTER TABLE documents DROP COLUMN file_blob;
VerificationRun these queries to confirm the fix worked before retrying the failed INSERT:
-- Max row size across the whole table
SELECT MAX(pg_column_size(t.*)) AS max_row_bytes
FROM your_table t;
-- Should be below 8160
-- Confirm TOAST table has out-of-line data
SELECT
c.relname AS toast_table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS toast_size
FROM pg_class c
JOIN pg_class parent ON parent.reltoastrelid = c.oid
WHERE parent.relname = 'your_table';
-- Re-attempt the insert that originally failed
INSERT INTO your_table (col1, col2, large_col) VALUES ('...', '...', '...');
-- Should succeed without ERROR: row is too big

