В PostgreSQL три строковых типа — text, varchar(n), char(n). Внутри сервера они хранятся одинаково и работают одинаково быстро. Разница только в семантике ограничения длины.

1. По умолчанию — text

PG-T-020 Если у поля нет бизнес-причины ограничивать длину — берём text.

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

Аргументы:

  1. text и varchar(n) хранятся одинаково (TOAST для длинных значений), скорость одинаковая. Длина — это CHECK-проверка перед записью, и больше ничего.
  2. varchar(255) пришёл из MySQL/Oracle, где varchar(255) действительно отличался от text. В PostgreSQL это просто text с CHECK (length <= 255) и неудобный путь миграции, если завтра окажется 256.
  3. Менять text на varchar(n) дешевле, чем расширять varchar(n) (последнее в старых версиях переписывало таблицу).

2. Когда длина оправдана

PG-T-021 Длину ставим, когда она — доменное правило, а не «техническое ограничение».

Хорошие случаи:

phone_e164      varchar(15)  NOT NULL,    -- E.164 ограничивает до 15 цифр
country_code    char(2)      NOT NULL,    -- ISO 3166-1 alpha-2: ровно 2 буквы
currency        char(3)      NOT NULL,    -- ISO 4217: ровно 3 буквы
inn             varchar(12)  NOT NULL,    -- ИНН ФЛ — 12, ЮЛ — 10

Плохие случаи:

full_name       varchar(255)             -- почему 255?
description     varchar(1000)            -- почему 1000?

Если завтра приходит русское имя в 4 слова с дефисами или клиент пишет описание длиннее лимита — будет 500-я ошибка на ровном месте. Берите text, валидация длины — на уровне приложения, если она нужна.

3. char(n) — почти никогда

char(n) — фиксированная длина с обязательным паддингом пробелами:

CREATE TABLE foo (code char(5));
INSERT INTO foo VALUES ('AB');
SELECT '[' || code || ']' FROM foo;   -- '[AB   ]'

PG-T-022 char(n) оправдан только для строго фиксированной длины из стандартаchar(2) для ISO-страны, char(3) для валюты, char(36) под формат UUID-строки (хотя и это неправильно — см. UUID, нужен тип uuid).

Для всего остального — паддинг пробелами создаёт сюрпризы при LIKE, =, length() и при сериализации в JSON.

4. Case-insensitive: citext или функциональный индекс

Логины, email, теги часто сравниваются без учёта регистра. Два рабочих подхода:

Подход 1 — расширение citext:

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';   -- найдёт

Подход 2 — обычный text + functional unique index:

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));

-- запросы должны явно использовать lower():
SELECT * FROM account WHERE lower(email) = lower('IVAN@EXAMPLE.COM');

PG-T-023 Для case-insensitive — citext либо lower() + functional index. Не LOWER() без индекса в каждом запросе.

citext короче в коде, но:

  • не работает с типизированными драйверами JDBC «из коробки» (приедет как text, проверьте, что jOOQ генерирует);
  • усложняет миграцию, если решите переехать на другой движок.

text + functional index — более портируемый вариант, работает везде.

5. Энкодинг

PG-T-024 Кластер должен быть UTF8. Локально-зависимые сборки баз — наследие.

SHOW server_encoding;   -- ожидаем UTF8

Это про инфраструктуру, не про DDL, но проверять стоит — иногда платформенные базы получают SQL_ASCII от старых deployment-скриптов, и потом эмодзи в комментариях покупателей ломают всё.

6. Длинные строки (TOAST)

PostgreSQL автоматически выносит длинные значения колонок в отдельное хранилище (TOAST). Порог — около 2 KB на запись. Это работает прозрачно: вы не управляете этим явно, но имеет смысл знать:

  • text с большими статьями/описаниями физически хранится отдельно от основной строки;
  • частое чтение «маленьких» колонок таблицы не задевает большие тексты;
  • EXPLAIN ANALYZE покажет ниже скорость, если запрос требует чтения toast-сегментов.

PG-T-025 Если в таблице есть и часто читаемый набор коротких полей, и редко-читаемый длинный текст (например, журнал событий с полем body), не разделяйте их на две таблицы преждевременно — TOAST уже это делает. Делите, только если измерения показывают узкое место.


Чек-лист на ревью

  • [ ] Нет varchar(255) и подобных «магических» длин без бизнес-обоснования.
  • [ ] Длина varchar(n) / char(n) соответствует доменному правилу (стандарт, формат, регуляторное ограничение).
  • [ ] char(n) использован только для строго фиксированной длины.
  • [ ] Case-insensitive поля защищены citext или functional unique index — не остались только в коде приложения.
  • [ ] Кластер в UTF8.

Связанные

  • Числа и точность.
  • UUID и идентификаторы — почему UUID не varchar(36).
  • Антипаттерны.