Опирается на правила: PG-T-020PG-T-025 из PostgreSQL Style Guide → раздел Строковые типы.

Важно знать

  • По умолчанию — text. Не varchar(255).
  • text, varchar(n), char(n) в PostgreSQL хранятся одинаково, отличаются только семантикой.
  • Длину ставим, когда это доменное правило (E.164, ISO 3166-1), не «техническое».
  • char(n) почти никогда — обязательный паддинг пробелами создаёт сюрпризы.
  • Case-insensitivecitext или functional unique index на lower().
  • Кластер должен быть UTF8SHOW server_encoding.
  • TOAST автоматически выносит длинные значения (>2KB) — не разделяй преждевременно.

PostgreSQL — не Oracle и не MySQL. В нём строковые типы внутри сервера хранятся одинаково (TOAST для длинных значений), работают одинаково быстро. Длина — это CHECK-проверка перед записью, и больше ничего. UCP формулирует: дефолт — text, длина — только при доменном правиле.

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

PG-T-020:

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

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

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

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

Сценарий поломки: русское имя «Анна-Виктория Александрова Петрова-Иванова» — 40+ символов с дефисами. varchar(255) нормально, но как только клиент напишет описание длиннее лимита — 500 Internal Server Error на ровном месте.

Для full_name, description — берём text. Валидация длины — на уровне приложения (Jakarta Validation @Size).

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

PG-T-022: только для строго фиксированной длины из стандарта.

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

char(5) для строки 'AB' хранит её как 'AB ' (с паддингом пробелов). Это создаёт сюрпризы:

  • LIKE 'AB%' — нужно LIKE 'AB%' (работает), но ='AB' vs 'AB ' (тонкость).
  • length() возвращает 5, не 2.
  • JSON serialization"AB " с пробелами.

Допустимо для:

  • char(2) — ISO 3166-1 страна.
  • char(3) — ISO 4217 валюта.

Для всего остального — text или varchar(n).

Case-insensitive

PG-T-023: два подхода.

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

Плюсы: код короче. Минусы:

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

Подход 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');

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

Что нельзя

-- ✗ — LOWER() без индекса
SELECT * FROM account WHERE LOWER(email) = LOWER('...');   -- seq scan

PostgreSQL не использует обычный b-tree индекс на email для LOWER(email) = .... Нужен functional index (lower(email)) или citext.

UTF8

PG-T-024: кластер всегда UTF8.

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

Локально-зависимые кодировки (SQL_ASCII, LATIN1, WIN1251) — наследие. На UTF8 эмодзи, multilingual content работают без ошибок.

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

TOAST для длинных строк

PG-T-025: автоматическое разделение.

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

Что это даёт:

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

Не разделяйте таблицу на основную + «details» преждевременно — TOAST уже это делает. Делите, только если измерения показывают узкое место.

Что запрещено

АнтипаттернПравилоЧто взамен
varchar(255) без причиныPG-T-020text
varchar(1000) для descriptionPG-T-021text
varchar(36) для UUIDPG-T-022тип uuid (см. UUID)
char(50) для phone, emailPG-T-022text или varchar(n) с E.164
LOWER(email) без functional indexPG-T-023functional index или citext
SQL_ASCII encodingPG-T-024UTF8
Разделение длинного text в отдельную таблицу преждевременноPG-T-025trust TOAST
Длина «потому что DBA сказал»PG-T-021доменное обоснование

Куда дальше

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