← Back to the section

If you're moving to PostgreSQL from MySQL or Oracle, the first thing that surprises you is that here people almost always just write text, with no number in parentheses. Let's figure out why.

what we write column type what is stored and read back text varchar(5) char(5) 'AB' 'AB' · 2 bytes 'AB' · 2 bytes 'AB···' · 5 bytes on disklength = 2, but JSON "AB " 'ABCDEFG' 'ABCDEFG' · 7 bytes insert rejected:value too long insert rejected:value too long dots stand for the spaces char(5) pads the string with, up to five characters

One value goes into three columns. text and varchar(5) keep exactly what you gave them; char(5) pads the string with spaces up to five characters — they are there on disk, but length no longer counts them. A value longer than the declared length only fits text: varchar(5) and char(5) answer with value too long.

Where varchar(255) came from

In MySQL and older Oracle versions, string types really did differ in how they were stored: varchar(255) took a different amount of disk space than text. That's why developers got used to specifying a length "just in case".

In PostgreSQL it doesn't work that way. Here text and varchar(n) are stored identically — through the same internal mechanism. varchar(255) is just text with an extra check "no longer than 255 characters" before every write. The speed is the same, the disk space is the same.

char(n) falls out of that pair: it pads the string with spaces up to the full length and stores them. pg_column_size shows how many bytes a value takes:

live example

SELECT pg_column_size('Ivanov'::text)         AS text_bytes,
       pg_column_size('Ivanov'::varchar(255)) AS varchar_bytes,
       pg_column_size('Ivanov'::char(20))     AS char_bytes;
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The answer: 10 | 10 | 24. The first two match byte for byte; char(20) padded six letters up to twenty characters and keeps the spaces on disk.

By default — text

For most string fields, the right type is text:

CREATE TABLE customer (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    full_name text NOT NULL,
    email     text NOT NULL,
    bio       text
);

Why text is better than varchar(255):

  • A string longer than 255 characters tomorrow costs no table migration.
  • In older PostgreSQL versions, extending varchar(n) rewrote the whole table. With text there's no such problem.
  • It explicitly signals: no domain rule governs the length here.

When you do need to specify a length

Set a length on a column when a real standard dictates it, not a feeling that "there shouldn't be more than a thousand characters here". Justified constraints look like this:

phone_e164   varchar(15)  NOT NULL,  -- E.164: at most 15 digits
country_code char(2)      NOT NULL,  -- ISO 3166-1 alpha-2: exactly 2 letters
currency     char(3)      NOT NULL,  -- ISO 4217: exactly 3 letters
inn          varchar(12)  NOT NULL,  -- INN: 10 digits (legal entity) or 12 (individual)

A counterexample — when the length is picked "out of thin air":

full_name    varchar(255),   -- why 255? what standard?
description  varchar(1000)   -- why 1000?

For full_name and description, take text and move the length constraint into application code, where it's easy to change and test:

// Jakarta Validation
public record CreateCustomerCommand(
    @NotBlank @Size(max = 200) String fullName,
    @Size(max = 5000)          String description
) {}

That way the business constraint stays in the application logic: if the business allows 300 characters tomorrow, you change one line of code, not the database schema.

char(n) — for fixed standards

char(n) is a fixed-length type: if the string is shorter than n, PostgreSQL pads it with spaces on the right. The nasty part is not the spaces themselves — it's that some operations hide them and others don't:

live example

SELECT '[' || 'AB'::char(5) || ']' AS concat,
       length('AB'::char(5))       AS len,
       octet_length('AB'::char(5)) AS bytes,
       'AB'::char(5) = 'AB'        AS eq,
       'AB'::char(5) LIKE 'AB'     AS like_ab,
       to_jsonb('AB'::char(5))     AS as_json;
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The answer: [AB] | 2 | 5 | t | f | "AB ". Five bytes sit on disk, but length counts two — it doesn't see the trailing spaces. Concatenation with || casts the value to text and cuts the spaces off, and comparison with = ignores them too. LIKE does not ignore them: LIKE 'AB' returns false where = 'AB' returned true. And in JSON the value travels in full — "AB ", five characters instead of the two the application wrote.

One more asymmetry: the cast 'ABCDEFG'::char(5) silently truncates the string to ABCDE, while inserting the same value into a char(5) column fails with value too long. The column is stricter than the cast.

When char(n) is appropriate:

  • char(2) — a country code per ISO 3166-1 (always exactly 2 letters).
  • char(3) — a currency code per ISO 4217 (always exactly 3 letters).

In all other cases — text or varchar(n).

Suppose an email has to be found regardless of case — IVAN@EXAMPLE.COM and ivan@example.com should hit the same record. PostgreSQL won't do that on its own: text is compared character by character, and case matters for it.

live example

SELECT 'IVAN@example.com' = 'ivan@example.com'               AS as_is,
       lower('IVAN@example.com') = lower('ivan@example.com') AS after_lower;
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The first comparison gives false, the second true. But a regular index on email won't help a query with LOWER(email) = LOWER(...): the index is built on the original value while the comparison uses a computed one, so you get a full table scan.

There are two working approaches.

Approach 1: the citext extension

citext is a special PostgreSQL type that automatically ignores case when comparing:

CREATE EXTENSION IF NOT EXISTS citext;

CREATE TABLE account (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email citext NOT NULL UNIQUE
);

INSERT INTO account (email) VALUES ('ivan@example.com');
SELECT * FROM account WHERE email = 'IVAN@EXAMPLE.COM';  -- will find it

Pro: the code is cleaner, the index works automatically. Con: some drivers can't map citext and return it as text — you need to check the behavior of your specific driver.

Approach 2: text + a functional index

The field stays text, but the index is built on the result of lower():

CREATE TABLE account (
    id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    email text NOT NULL
);

CREATE UNIQUE INDEX account_email_lower_uk ON account (lower(email));

Then the query has to be written explicitly:

live example

SELECT * FROM customer WHERE lower(email) = lower('A.VOLKOVA@EXAMPLE.COM');
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Pro: works without extensions, portable. Con: every query must use lower() — if you forget, the index won't be used.

The UTF8 encoding

A PostgreSQL cluster has an encoding that is set when the database is created. To check the current one:

live example

SHOW server_encoding;
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

If the encoding is not UTF8 (for example, SQL_ASCII or WIN1251), multilingual content and emoji break. This happens on old installations created by outdated instructions — for a new cluster UTF8 is the only correct choice.

TOAST: long strings are stored automatically

A field with long text — an article, a product description, a biography — doesn't need to be moved into a separate table by hand. PostgreSQL does it through the TOAST mechanism: values longer than ~2 KB are physically stored apart from the main table row, and a query that doesn't touch the large field never reads it.

CREATE TABLE article (
    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    slug    text NOT NULL UNIQUE,
    title   text NOT NULL,
    body    text NOT NULL    -- long text — TOAST handles it on its own
);

Splitting into article + article_body only makes sense if measurements reveal a real bottleneck. Without measurements — trust TOAST.

In short

  • text and varchar(n) are stored identically and run at the same speed: varchar(255) is the same text with a length check before every write.
  • varchar(255) "just in case" is a habit from MySQL and Oracle; write text and keep the length limit in application code.
  • Set a length on a column when a standard dictates it: E.164, ISO 3166-1, ISO 4217.
  • char(n) pads with spaces and stores them: length, || and = hide them, LIKE and JSON output do not. Only for strictly fixed codes.
  • Case-insensitive search: citext or a unique index on lower(field); LOWER(field) in WHERE without it means a full table scan.
  • The cluster encoding should be UTF8; TOAST moves long text into separate storage by itself.