← назад к разделу

Если вы переходите на PostgreSQL с MySQL или Oracle, первое, что удивляет — здесь почти всегда пишут просто text, без цифры в скобках. Разбираемся почему.

Откуда взялся varchar(255)

В MySQL и старых версиях Oracle строковые типы действительно различались по устройству хранения. varchar(255) физически занимал другой объём на диске, чем text. Поэтому разработчики привыкли ставить длину «на всякий случай».

В PostgreSQL так не работает. Здесь text, varchar(n) и char(n) хранятся одинаково — через один и тот же внутренний механизм. varchar(255) — это просто text с дополнительной проверкой «не длиннее 255 символов» перед каждой записью. Скорость одинакова, место на диске одинаково.

Итог: varchar(255) в PostgreSQL — это устаревшая привычка, которая добавляет ограничение без какого-либо технического смысла.

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

Для большинства строковых полей правильный тип — text:

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

Почему text лучше, чем varchar(255):

  • Если завтра понадобится хранить строку длиннее 255 символов — не нужна миграция таблицы.
  • В старых версиях PostgreSQL расширение varchar(n) переписывало всю таблицу. С text такой проблемы нет.
  • Явно показывает: длина не регламентирована доменным правилом.

Когда длину указывать всё-таки нужно

Длину в колонке имеет смысл ставить, когда она продиктована реальным стандартом, а не ощущением «тут не должно быть больше тысячи символов».

Примеры обоснованных ограничений:

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,  -- ИНН: 10 цифр (юрлицо) или 12 (физлицо)

Антипример — когда длина взята «из головы»:

full_name    varchar(255),   -- почему 255? какой стандарт?
description  varchar(1000)   -- почему 1000?

Для full_name и description правильно взять text, а ограничение по длине вынести в код приложения, где его легко изменить и протестировать:

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

Так бизнес-ограничение остаётся там, где ему место — в логике приложения. Если бизнес решит, что теперь можно 300 символов, меняется одна строчка в коде, а не схема базы данных.

char(n) — для фиксированных стандартов

char(n) — тип с фиксированной длиной. Если строка короче n, PostgreSQL дополняет её пробелами справа.

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

Это создаёт неожиданные проблемы:

  • length(code) вернёт 5, а не 2.
  • При сериализации в JSON получите "AB " с пробелами.
  • Сравнения могут вести себя не так, как ожидается.

Когда char(n) уместен:

  • char(2) — код страны по ISO 3166-1 (всегда ровно 2 буквы).
  • char(3) — код валюты по ISO 4217 (всегда ровно 3 буквы).

Во всех остальных случаях — text или varchar(n).

Поиск без учёта регистра

Допустим, нужно хранить email и искать по нему без учёта регистра — IVAN@EXAMPLE.COM и ivan@example.com должны находить одну запись.

Обычный индекс по email тут не поможет: запрос WHERE LOWER(email) = LOWER(...) не использует индекс по исходному полю и будет сканировать всю таблицу.

Есть два рабочих подхода.

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

citext — это специальный тип PostgreSQL, который при сравнении автоматически игнорирует регистр:

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

Плюс: код чище, индекс работает автоматически. Минус: некоторые драйверы не умеют маппить citext и возвращают его как text — нужно проверять поведение конкретного драйвера.

Подход 2: text + функциональный индекс

Поле остаётся text, но индекс строится по результату 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));

Тогда запрос нужно писать явно:

SELECT * FROM account WHERE lower(email) = lower('IVAN@EXAMPLE.COM');

Плюс: работает без расширений, портируемо. Минус: каждый запрос обязан использовать lower() — если забыть, индекс не задействуется.

Кодировка UTF8

Кластер PostgreSQL имеет кодировку, которая задаётся при создании базы данных. Проверить текущую:

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

Если кодировка не UTF8 (например, SQL_ASCII или WIN1251), возникают проблемы с многоязычным контентом и эмодзи. Такое встречается на старых инсталляциях, созданных по устаревшим инструкциям.

UTF8 — единственная правильная кодировка для нового кластера.

TOAST: длинные строки хранятся автоматически

Если в таблице есть поле с длинным текстом — статья, описание товара, биография — не нужно выносить его в отдельную таблицу руками.

PostgreSQL автоматически делает это сам через механизм TOAST: значения длиннее ~2 КБ физически хранятся отдельно от основной строки таблицы. При запросе, который не обращается к большому полю, PostgreSQL его не читает — и это ускоряет работу.

CREATE TABLE article (
    id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    slug    text NOT NULL UNIQUE,
    title   text NOT NULL,
    body    text NOT NULL    -- длинный текст — TOAST справится сам
);

Разделять на article + article_body имеет смысл, только если измерения показывают реальное узкое место. Без измерений — доверьтесь TOAST.

Коротко

  • В PostgreSQL text, varchar(n) и char(n) хранятся одинаково — только семантика разная.
  • varchar(255) в PostgreSQL — устаревшая привычка из MySQL/Oracle; по умолчанию пишите text.
  • Длину (varchar(n)) ставьте, когда есть доменный стандарт: E.164, ISO 3166-1 и подобные.
  • char(n) дополняет строку пробелами — используйте только для строго фиксированных кодов из стандартов.
  • Поиск без учёта регистра: citext или функциональный индекс (lower(field)).
  • Простой LOWER(field) в WHERE без функционального индекса — сканирование всей таблицы.
  • Кодировка кластера должна быть UTF8.
  • Длинные тексты TOAST выносит в отдельное хранилище автоматически — не делайте это вручную без измерений.

Что почитать дальше

  • Числа и точность — bigint, numeric, float.
  • Время и таймзоны — timestamptz.
  • UUID и идентификаторы — тип uuid, не char(36).
  • JSONB — когда оправдан, когда нет — для структурированных данных.