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.
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. Withtextthere'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).
Case-insensitive search
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
textandvarchar(n)are stored identically and run at the same speed:varchar(255)is the sametextwith a length check before every write.varchar(255)"just in case" is a habit from MySQL and Oracle; writetextand 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,LIKEand JSON output do not. Only for strictly fixed codes.- Case-insensitive search:
citextor a unique index onlower(field);LOWER(field)inWHEREwithout it means a full table scan. - The cluster encoding should be UTF8; TOAST moves long text into separate storage by itself.
What to read next
- Numbers and precision — bigint, numeric, float.
- Time and time zones — timestamptz.
- UUID and identifiers — the
uuidtype, notchar(36). - JSONB — when it's justified, when it's not — for structured data.