7 min read

VARCHAR(255) in MySQL vs text in PostgreSQL: Dropping the Habit

VARCHAR(255) is a MySQL-era reflex with real historical reasons behind it. Here is where the habit came from, why PostgreSQL teams reach for text plus CHECK constraints, and which performance claims survive scrutiny.

Open any schema that has passed through enough MySQL hands and you will find it: VARCHAR(255), everywhere, on columns that will never hold more than twenty characters and columns that regularly need three hundred. When those schemas migrate to PostgreSQL, the habit usually comes along for the ride. It should not, and explaining why requires giving the habit its due first, because it was not always cargo cult. There were real constraints behind that number.

Where 255 actually came from

Two historical facts made 255 the default. First, before MySQL 5.0.3, VARCHAR simply could not exceed 255 characters, so 255 meant "as long as possible." Second, and more durable: a VARCHAR of 255 or fewer characters needs only a 1-byte length prefix in storage, while anything longer needs 2 bytes. For years, 255 was the largest length that cost nothing extra to declare.

Then indexing kept the number alive. Older InnoDB row formats capped index key prefixes at 767 bytes. With the 3-bytes-per-character utf8mb3 charset, 255 characters is 765 bytes: the largest round length that could still be fully indexed. When the ecosystem moved to utf8mb4 at 4 bytes per character, that same 767-byte cap produced the famous 191-character limit that a generation of Rails and Drupal migrations encoded into schemas. Modern MySQL with the DYNAMIC row format raised the prefix cap to 3072 bytes, which is why you rarely hit these walls on a current 8.x install, but the reflex had a decade to set.

ORMs then institutionalized it. Rails migrations turned string into VARCHAR(255) unless told otherwise, Django's CharField demanded a max_length and tutorials filled in 255, and Hibernate's default column length is 255 to this day. A generation of schemas got the number not from a DBA's judgment but from a framework default that traced back to a storage-format boundary in an engine the application may never have run on.

What VARCHAR(255) costs and does not cost in MySQL

On disk, nothing: InnoDB stores only the actual string length plus the prefix byte, so VARCHAR(255) and VARCHAR(50) holding the same value occupy identical space. The declared length is not padding.

The historical cost was in memory. When a query forced an internal temporary table using the MEMORY engine, or sized sort buffers, VARCHAR columns were handled at their maximum declared width, so a query materializing a million rows of VARCHAR(255) that averaged 20 characters allocated far more memory than the data required. This was the legitimate argument for declaring realistic lengths. MySQL 8.0 blunted it: the default TempTable engine for internal temporary tables stores variable-length types compactly. Blunted, though, is not eliminated; there are still paths where declared width matters, and "declare roughly what you mean" remains fair advice on MySQL.

What VARCHAR(255) never did was validate anything useful. No email address, URL, or display name has a natural 255-character boundary. The limit exists at a storage-format frontier, not a domain frontier, which is exactly why it communicates nothing to the next engineer reading the schema.

How PostgreSQL treats varchar and text

PostgreSQL removes the entire premise. varchar(n), varchar without a length, and text are the same varlena type under the hood: same storage layout, same TOAST behavior for large values, same operators, same indexing. The documentation says it plainly and benchmarking confirms it: there is no performance difference among the three. varchar(n) is text plus an inserted length check, and char(n) is worse than both because of space padding; avoid it entirely.

So the idiomatic PostgreSQL schema uses text, and expresses real domain limits as constraints:

-- PostgreSQL: text plus an explicit, changeable domain rule
CREATE TABLE users (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email text NOT NULL,
  display_name text NOT NULL,
  CONSTRAINT display_name_length
    CHECK (char_length(display_name) <= 80)
);

The CHECK constraint beats varchar(n) on operability. Loosening a varchar length is a metadata-only ALTER on modern PostgreSQL, but changing any other property of the limit means type surgery. A CHECK constraint is dropped and re-added as an ordinary DDL operation, can encode the real rule rather than just a ceiling, and states the domain intent in a named, greppable object:

-- Changing the rule later is routine DDL, not type surgery
ALTER TABLE users DROP CONSTRAINT display_name_length;
ALTER TABLE users ADD CONSTRAINT display_name_length
  CHECK (char_length(display_name) <= 120) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT display_name_length;

The NOT VALID then VALIDATE pattern matters on large tables: it takes the brief lock to add the rule without scanning existing rows, then validates in the background with a weaker lock. That is a workflow varchar(n) cannot offer.

The one real limit to respect in PostgreSQL

Unbounded text is not consequence-free. A B-tree index entry must fit in roughly a third of an 8 KB page, about 2704 bytes, so a B-tree over an unconstrained text column will error at insert time if someone stores a novel in it. The practical pattern for genuinely long searchable text is indexing an expression over a prefix, hashing, or moving to full-text search. For ordinary identifier-ish columns this never triggers, and a sane CHECK constraint documents that it cannot. This is also where an index advisor earns its keep: the right index for a text column depends on the query shape, and equality, prefix LIKE, and trigram search all want different structures.

Performance myths, both directions

Myth one, from the MySQL side: "smaller VARCHAR declarations make PostgreSQL faster." They do not; the length check is overhead, not optimization, and the planner does not use declared lengths the way people imagine.

Myth two, from the PostgreSQL side: "VARCHAR(255) was always pure superstition." Not quite; on MySQL the declared width had real memory consequences in temp-table and sort paths, and partially still does. The habit was rational in its habitat. The mistake is exporting it to an engine where the reasoning does not apply.

Myth three, in both camps: "text columns are slow." In PostgreSQL, text is the native string type that everything else is defined in terms of. In MySQL, TEXT is a genuinely different type with different temp-table and indexing behavior than VARCHAR, which is where this myth immigrated from. The two engines using the same word for different things has caused more schema-review arguments than any benchmark ever settled.

Migration mapping in practice

My default mapping table is short. MySQL VARCHAR(n) becomes text, plus a CHECK constraint only where n encoded a real domain rule someone can defend. MySQL CHAR(n) becomes text as well, after confirming nothing depends on the space padding. MySQL TEXT, MEDIUMTEXT, and LONGTEXT all collapse into text, since PostgreSQL has no size-tiered string types; TOAST handles large values transparently. While you are there, drop the columns that were 255 for no reason from your mental model entirely, and let the PostgreSQL side of the schema say what it means.

Charset baggage stays behind: PostgreSQL databases have one encoding, almost always UTF-8, and collation is handled per-database or per-column without the utf8mb3-vs-utf8mb4 split. Verify that your MySQL data is actually valid in its declared charset before transfer; latin1 columns holding UTF-8 bytes are a classic migration landmine.

Expect pushback during schema review, and meet it with the documentation rather than volume. Engineers who spent years on MySQL have earned their instincts, and "text everywhere" genuinely was bad advice in their previous habitat. The persuasive framing is not that limits are bad, but that PostgreSQL lets you put the limit where the domain logic lives instead of where a 1990s storage format needed it.

After you land on PostgreSQL: MonPG

Schema-shape decisions like text-plus-CHECK are cheap to make and slow to verify, because their costs show up as workload behavior: a VALIDATE CONSTRAINT that blocks longer than expected, an index that stops being used after a query rewrite, TOAST churn from a column that turned out to hold bigger values than anyone declared. MonPG monitors PostgreSQL only, and this is the period where that focus pays: pg_stat_statements history to compare query families across the migration, lock visibility while constraints validate on big tables, and index usage trends that show whether the new text indexes actually serve the workload. The PostgreSQL monitoring guide lays out the baseline worth having before the first post-migration deploy, not after it.