Базовые типы: деньги, текст, время, идентичность
У Postgres точная система типов. Выбирай numeric (не float) для денег, timestamptz (никогда timestamp) для времени и правильную версию uuid — дефолты кусаются в проде.
Биллинг хранил цены в колонке double precision, потому что «это же число». Два года никто не замечал. Потом аудитор просуммировал 4,2 млн позиций — и итог разошёлся на $1847. Не украли, а округлили по доле цента за раз. 0.1 + 0.2 в плавающей точке не равно 0.3, и база — худшее место узнавать это. Фикс — одна строка смены типа и болезненный backfill. Тип, выбранный в первый день, — это решение, с которым живёшь годами.
Семейства типов: выбирай по смыслу
Postgres группирует встроенные типы в несколько семейств. Хорошее моделирование начинается с того, что каждую колонку сопоставляют семейству, отвечающему тому, что значение означает, а не тому, что проще набрать. Прежде чем создать следующую колонку, спроси себя: что это значение представляет — счётчик, момент времени, идентификатор — и делает ли выбранный тип этот смысл обязательным или просто подразумевает его?
Числа: никогда не храни деньги во float
integer — 4-байтовое целое со знаком (до ~2,1 млрд); bigint — 8 байт (до ~9,2 квинтиллиона). Используй bigint для всего, что считает события, строки или деньги-в-центах на масштабе — переполнение integer на первичном ключе — это реальный инцидент.
Жёсткое правило: деньги — это numeric или целые центы, никогда real/double precision. numeric — точная десятичная дробь: хранит заданные цифры и считает в десятичной системе, так что 0.10 + 0.20 ровно 0.30. Плавающая точка хранит двоичное приближение, а у 0.1 нет точного двоичного представления. Посмотри сам:
SELECT 0.1::float8 + 0.2::float8 AS float_sum, -- 0.30000000000000004
0.1::numeric + 0.2::numeric AS exact_sum; -- 0.3
-- денежная колонка как надо:
CREATE TABLE orders (
id bigint PRIMARY KEY,
total_cents bigint NOT NULL, -- целые центы: быстро, точно, без аргумента scale
tax numeric(12, 2) NOT NULL -- или numeric с фиксированной точностью
);numeric(12, 2) значит до 12 значащих цифр, из них 2 после запятой. Компромисс: арифметика numeric эмулируется программно и примерно в 10–100× медленнее нативной float-математики CPU, а каждое значение переменной ширины. Для денег это не проблема — правит корректность, — но не тяни numeric в горячую научную агрегацию, где крошечная погрешность округления допустима.
Текст: text и varchar(n) одинаковы по скорости
Стойкий миф: varchar(50) быстрее или меньше, чем text. В Postgres это не так. Все три — text, varchar(n) и varchar — делят одинаковое хранение и одни и те же операторы. Единственное, что varchar(n) добавляет, — это check-ограничение на длину: Postgres проверяет, что каждая запись не длиннее n символов, и отвергает более длинные с ошибкой.
Значит, выбор только в том, нужно ли тебе это ограничение. Лимит длины, отражающий реальное бизнес-правило (2-символьный код страны), — хорошее моделирование; произвольный varchar(255), скопированный из MySQL-туториала, — шум. Postgres-нативная привычка — text плюс явный CHECK, когда граница действительно нужна: яснее, и можно менять границу без неуклюжего alter типа.
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL,
country text NOT NULL CHECK (char_length(country) = 2) -- реальное правило, явно
);Время: timestamptz, всегда
Это самая частая ошибка моделирования, и тихая. Есть два типа timestamp:
timestamp(он жеtimestamp without time zone) хранит значение по настенным часам и выбрасывает зону. Это наивное «2026-06-02 14:00» без привязки к реальному моменту.timestamptz(timestamp with time zone) хранит абсолютный момент. На входе Postgres конвертирует ввод в UTC и хранит его; на выходе рендерит в зоне сессииTimeZone. Он не хранит исходную зону — он хранит момент.
timestamptz — правильный дефолт практически для любого события, строки лога, created_at и дедлайна. С простым timestamp два сервера в разных зонах, вставляя «сейчас», пишут разные абсолютные моменты, которые сравниваются неверно, и истину потом не восстановить. Храни момент; пусть приложение решает, как его показать.
CREATE TABLE events (
id bigint PRIMARY KEY,
occurred_at timestamptz NOT NULL DEFAULT now(), -- абсолютный момент в UTC
kind text NOT NULL
);
-- date — для календарных дат без времени; interval — для интервала:
SELECT occurred_at::date AS day,
now() - occurred_at AS age -- age это interval
FROM events;Используй date для настоящих календарных дат (день рождения, день биллинга) и interval для промежутков («7 дней», «90 минут») — вычитание двух timestamptz даёт interval.
UUID: случайный v4 против упорядоченного по времени v7
uuid — 16-байтовый тип для ключей, генерируемых без центральной последовательности: полезен между шардами или до того, как строка дойдёт до базы. Версия важна для локальности индекса:
- v4 полностью случаен. Каждая вставка попадает в случайное место в B-tree первичного ключа, так что у индекса нет горячего хвоста — записи разбросаны по страницам, hit rate кеша падает, а индекс фрагментируется и пухнет под тяжёлой нагрузкой вставок.
- v7 встраивает миллисекундную метку времени в старшие биты, так что значения примерно упорядочены по времени. Вставки приклеиваются к правому краю B-tree, как serial, держа индекс компактным и дружелюбным к кешу.
Для таблицы с высоким темпом вставок v7 (или bigint identity) пишется ощутимо быстрее и куда меньше склонен к bloat индекса, чем v4. Тянись к случайному v4 только когда неугадываемость самого ключа — требование.
▸Граничные случаи
Postgres поставляет встроенный случайный генератор (gen_random_uuid(), это v4) в ядре. Нативный uuidv7() появился в Postgres 18; на старых версиях v7 генерируют в приложении или расширением. Вывод не «всегда v7», а в том, что физический порядок ключа правит производительностью записи в индекс, так что случайный ключ на write-heavy таблице — осознанный компромисс, а не бесплатный дефолт.
Касты: будь явным на границе
Postgres неявно кастует в одних контекстах и отказывается в других; опора на неявные касты — это как получить неожиданные планы (индекс пропущен, потому что сравнение вынудило каст) или ошибки времени выполнения. Делай касты явными через ::type или CAST(x AS type) на краях запроса, особенно при сравнении колонки с литералом другого типа.
SELECT '42'::int + 1 AS to_int, -- 43
'2026-06-02'::date AS to_date,
total_cents::numeric / 100 AS dollars -- центы → значение для показа
FROM orders;Почему деньги нельзя хранить в колонке double precision (float)?
Что на самом деле хранит колонка timestamptz?
Заполни пропуск: text и varchar(n) хранят данные в Postgres идентично; единственное, что varchar(n) добавляет, — это _______ длины, отвергающий строки длиннее n.
- 01Два способа корректно хранить деньги в Postgres и почему float неверен.
- 02Что хранит timestamptz и почему предпочесть его timestamp?
- 03varchar(50) быстрее или меньше, чем text в Postgres? В чём реальная разница и что с uuid v4 против v7?
Моделирование с Postgres начинается с колонки: выбирай тип по тому, что значение означает. Деньги — numeric (точная десятичная) или целые центы, никогда real/double precision, потому что двоичная плавающая точка не представляет 0.1 точно, и погрешность округления накапливается в неверные итоги. Время — timestamptz, хранящий абсолютный момент в UTC и рендерящий в зоне сессии; простой timestamp выбрасывает зону и даёт серверам в разных зонах писать несравнимые значения. text и varchar(n) идентичны по хранению и скорости — varchar(n) лишь добавляет проверку длины, так что предпочитай text плюс явный CHECK для реальных правил. Для ключей предпочитай bigint identity или упорядоченный по времени uuid v7, чтобы держать индекс компактным; случайный v4 фрагментирует B-tree и оправдан лишь когда ключ должен быть неугадываемым. И кастуй явно на границах запроса, чтобы планировщик видел задуманное сравнение. Теперь, когда встретишь в ревью схемы колонку double precision для денег или timestamp без зоны — ты будешь знать, какой тихий сбой там живёт, и к какому однострочному фиксу тянуться.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.