Fix PostgreSQL 'row is too big: size exceeds maximum' Error on INSERT

intermediate๐Ÿ˜ PostgreSQL2026-06-03| PostgreSQL 12โ€“16 on Linux, macOS, Windows

Error Message

ERROR: row is too big: size 8208, maximum size 8160
#postgresql#row-size#toast#storage#large-object

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

Storage Strategy ReferenceStrategyCompressOut-of-lineBest forPLAINNoNoFixed-size types (int, float)MAINYes (first)Yes (last resort)Columns you want inline if possibleEXTENDEDYes (first)Yestext, varchar, jsonb, xml (default)EXTERNALNoYesbytea where random access mattersTEXT, VARCHAR, BYTEA, and JSONB already default to EXTENDED. If your diagnostic query shows MAIN on any of those types, someone changed it explicitly at some point โ€” switching it back is usually the only fix you need.

Related Error Notes