The schema conversion looked trivial: a content table with an id, some metadata, and about forty VARCHAR(255) columns carrying localized strings. On the legacy MySQL 5.6 system it came from, it existed and worked. The CREATE TABLE against the MySQL 8.0 target died immediately: ERROR 1118 (42000): Row size too large (> 8126). Forty columns times 255 characters times four bytes for utf8mb4 is over 40KB of declared width, and InnoDB was not having it. Meanwhile the PostgreSQL translation of the same table created without a murmur, and the team declared victory until the migration's index step, where PostgreSQL produced its own surprise: ERROR: index row size 3456 exceeds btree version 4 maximum 2704 for index "content_url_key".
Both engines store large values partly outside the main row. Both do it transparently enough that you can run them for years without thinking about it. And both have hard limits in exactly the places the abstraction leaks: row width on the page for InnoDB, index entry width for PostgreSQL's B-tree. This article is the map of both storage schemes, the limits with their actual error texts, and what to do when you hit them.
Why did MySQL reject a table PostgreSQL accepted?
Because InnoDB requires every row's on-page portion to fit in about half of a 16KB page, roughly 8126 bytes, while PostgreSQL imposes no comparable row-width limit for storage at all. InnoDB pages are 16KB by default and the B-tree needs at least two records per page to function, so a row whose on-page bytes exceed the half-page budget cannot be stored, full stop. With utf8mb4, a VARCHAR(255) declares up to 1020 bytes, and under the older COMPACT row format most of that declared width counts against the on-page budget. Forty such columns do not fit, hence ERROR 1118 at CREATE TABLE time. There is a second, independent limit: MySQL rejects any table whose declared row width exceeds 65535 bytes regardless of row format, which is why the same schema fails even after the row-format fix if you push the column count higher.
PostgreSQL has no half-page rule because its storage layer was built around moving large values out of the row from day one. A field can hold up to 1GB, a row can be enormous, and the table creates happily. PostgreSQL's leak appears later, at the index layer, which is the second half of this article.
How does InnoDB store oversized values off-page?
It splits the row: variable-length columns that do not fit are moved to overflow pages, and the row keeps either a prefix of the value or just a pointer, depending on the row format. Under COMPACT and REDUNDANT, InnoDB stores the first 768 bytes of each variable-length column on the page and the remainder on overflow pages; those prefixes are exactly what count against the 8126-byte budget, which is why the 40-column table failed. Under DYNAMIC, the default since MySQL 5.7 and the format you should treat as the only sane choice, InnoDB stores just a 20-byte pointer on the page when a column must overflow and keeps the entire value off-page, which drops the on-page cost of each of those forty columns dramatically. COMPRESSED behaves like DYNAMIC with zlib compression applied to the overflow data, at a CPU price that rarely pays for itself on modern hardware.
The fix for the migration was therefore unglamorous: ensure ROW_FORMAT=DYNAMIC on the target, convert the worst offenders from VARCHAR to TEXT where the application allowed it, and split genuinely wide metadata into a side table. No storage trick makes forty kilobytes of declared width a good idea; the row format just decides how loudly InnoDB objects.
-- MySQL 8.0: check what you actually have
SELECT table_name, row_format
FROM information_schema.tables
WHERE table_schema = 'content';
ALTER TABLE content_items ROW_FORMAT=DYNAMIC;
-- PostgreSQL: every large column already has a TOAST strategy
SELECT attname, attstorage
FROM pg_attribute
WHERE attrelid = 'content_items'::regclass
AND attnum > 0
ORDER BY attnum;
-- attstorage: p=PLAIN, x=EXTENDED, e=EXTERNAL, m=MAIN
How does PostgreSQL TOAST work?
PostgreSQL compresses and then relocates any oversized field value into a hidden side table automatically, once the row threatens to exceed about 2KB. The mechanism is TOAST: when a tuple grows past the roughly 2KB threshold, PostgreSQL first tries compression, pglz by default with lz4 available since PostgreSQL 14 via default_toast_compression, and if compression is not enough it slices the value into chunks stored in the table's associated TOAST relation, leaving an 18-byte pointer in the main tuple. The attstorage strategies above are the per-column controls: EXTENDED allows compression and out-of-line storage, EXTERNAL allows out-of-line without compression, MAIN prefers compression but keeps the value inline as long as possible, and PLAIN opts out entirely, which is what fixed-width types use.
The transparency is genuinely good, and the costs are real but specific. Reading a TOASTed value means fetching chunks from the TOAST table and reassembling, so a query that selects the big column pays extra I/O per row, while a query that never touches it pays nothing, because the out-of-line chunks are not read. That asymmetry is the single most useful fact about TOAST in practice: keep the hot columns small and let the fat ones float out of line, and PostgreSQL largely optimizes itself. The deeper mechanics, including the interaction with GIN indexes on JSONB, are covered in the TOAST and large values piece and the JSONB performance article.
Why do the limits reappear at the index layer?
Because an index entry must fit on a single index page, and neither engine's off-page trick applies inside the index. On MySQL with DYNAMIC or COMPRESSED row format, an index key may run to 3072 bytes, which utf8mb4 reaches in 768 characters; a VARCHAR(1024) utf8mb4 column simply cannot be fully indexed, and the historical fix of prefix indexing trades uniqueness enforcement away. On PostgreSQL, a B-tree index entry is limited to about 2704 bytes, one-third of an 8KB page minus overhead, and the insert or index build fails with the exact error from our migration: index row size exceeds btree version 4 maximum 2704.
The workarounds differ in flavor. On PostgreSQL the standard escape is indexing a digest of the value rather than the value itself: an expression index over md5() or a hash of the column, or a hash index for pure equality lookups, keeps entries small and uniqueness enforceable through the digest:
-- PostgreSQL: enforce uniqueness on long values via a digest
CREATE UNIQUE INDEX content_url_digest_key
ON content_items (md5(url));
-- MySQL: prefix index, smaller but not unique-enforcing
CREATE INDEX content_url_prefix ON content_items (url(191));
The prefix length 191 is not superstition: 191 characters times four utf8mb4 bytes plus length overhead sits under the old 767-byte InnoDB key limit from the pre-DYNAMIC era, and the habit stuck around in schemas long after 3072 became available. Either way, the lesson generalizes: off-page storage saves the table and does nothing for the index, so the widest column you can store is not the widest column you can index, on either engine.
How MonPG watches the storage layer
Oversized-value problems are capacity problems in disguise: TOAST tables growing faster than the tables they serve, overflow-heavy InnoDB tables whose effective rows-per-page keeps dropping, and the read amplification that appears when queries start touching out-of-line data at scale. MonPG's PostgreSQL monitoring tracks table and index growth with bloat signals alongside query history, so the week a new feature starts writing 100KB payloads into a table designed for 2KB ones shows up in the growth trend before it shows up in latency.
MonPG monitors PostgreSQL today; MySQL support is on the roadmap. When it lands, the InnoDB side of this article is what it will surface: row format inventory, table growth rates that flag overflow-heavy layouts, and the query-level evidence of wide-row reads getting expensive. Until then, the MySQL monitoring page tracks that work, and the portable lesson needs no tooling: know where your engine draws its line, 8126 bytes on-page for InnoDB and 2704 bytes in the B-tree for PostgreSQL, because the error arrives at CREATE time and the design review is the only cheap place to catch it.