Тип столбца задаёт, сколько байт значение занимает в таблице и в каждом индексе, и в каком порядке ключи ложатся в B-tree. Объявление таблицы разобрано в статье про CREATE TABLE; здесь типы измеряются. int и bigint весят одинаково, varchar(100) не экономит байт, numeric считает вдвое дольше bigint, а uuid v4 вставляется в 4,7 раза дольше v7: 68,4 против 14,7 с на 10 млн строк.
Куда попадает вставка: 10 млн строк в первичном ключе
bigint, uuid v7) пишет в правый край и оставляет листья заполненными на 90 %. Случайный uuid v4 попадает в любой лист, делит листья пополам и за 10 млн вставок прогоняет через shared_buffers 3 074 268 страниц.Опыт 1: сколько байт стоит значение
Контейнер и таблица с одной строкой по одному значению каждого типа; pg_column_size показывает размер значения, как оно лежит в строке:
docker run -d --name dba-data-types-on-the-scale -e POSTGRES_PASSWORD=pg postgres:18
docker exec -it dba-data-types-on-the-scale psql -U postgres
CREATE TABLE s (i2 int2, i4 int4, i8 int8, f8 float8, ts timestamptz, u uuid, t text, v varchar(100), c char(100));
INSERT INTO s VALUES (1, 1, 1, 1.5, now(), gen_random_uuid(), 'abc', 'abc', 'abc');
SELECT pg_column_size(i2) i2, pg_column_size(i4) i4, pg_column_size(i8) i8, pg_column_size(f8) f8, pg_column_size(ts) tstz,
pg_column_size(u) uuid, pg_column_size(t) text, pg_column_size(v) varchar100, pg_column_size(c) char100 FROM s;
вывод
i2 | i4 | i8 | f8 | tstz | uuid | text | varchar100 | char100
----+----+----+----+------+------+------+------------+---------
2 | 4 | 8 | 8 | 8 | 16 | 4 | 4 | 101
(1 row)
'abc' в text и в varchar(100) занимает по 4 байта: 3 символа и однобайтовый заголовок. Лимит 100 ничего не резервирует, это проверка длины при вставке. char(100) дополняет значение пробелами до 100 символов, и в строку уходит 101 байт. Длинные значения text вытесняются в TOAST, см. значения в TOAST.
Размер значения ещё не размер строки: строка и индексная запись выравниваются, см. заголовок строки и выравнивание. Семь таблиц по миллиону строк и индексы по int4 и int8:
CREATE TABLE w_i4 (a int4); CREATE TABLE w_i8 (a int8);
CREATE TABLE w_i4i4 (a int4, b int4); CREATE TABLE w_i8i8 (a int8, b int8);
CREATE TABLE w_t (a text); CREATE TABLE w_v (a varchar(100)); CREATE TABLE w_c (a char(100));
INSERT INTO w_i4 SELECT g FROM generate_series(1,1000000) g;
INSERT INTO w_i8 SELECT g FROM generate_series(1,1000000) g;
INSERT INTO w_i4i4 SELECT g,g FROM generate_series(1,1000000) g;
INSERT INTO w_i8i8 SELECT g,g FROM generate_series(1,1000000) g;
INSERT INTO w_t SELECT 'abc' FROM generate_series(1,1000000);
INSERT INTO w_v SELECT 'abc' FROM generate_series(1,1000000);
INSERT INTO w_c SELECT 'abc' FROM generate_series(1,1000000);
CREATE INDEX ON w_i4 (a); CREATE INDEX ON w_i8 (a);
SELECT relname, pg_relation_size(oid) AS bytes, pg_relation_size(oid)/8192 AS pages,
round(pg_relation_size(oid)::numeric/1000000,1) AS bytes_per_row
FROM pg_class WHERE relname IN ('w_i4','w_i8','w_i4i4','w_i8i8','w_t','w_v','w_c','w_i4_a_idx','w_i8_a_idx') ORDER BY 1;
вывод
relname | bytes | pages | bytes_per_row
------------+-----------+-------+---------------
w_c | 134299648 | 16394 | 134.3
w_i4 | 36249600 | 4425 | 36.2
w_i4_a_idx | 22487040 | 2745 | 22.5
w_i4i4 | 36249600 | 4425 | 36.2
w_i8 | 36249600 | 4425 | 36.2
w_i8_a_idx | 22487040 | 2745 | 22.5
w_i8i8 | 44285952 | 5406 | 44.3
w_t | 36249600 | 4425 | 36.2
w_v | 36249600 | 4425 | 36.2
(9 rows)
Одна колонка int4 и одна int8 дают одинаковые 36 249 600 байт таблицы и 22 487 040 байт индекса. Заголовок строки занимает 24 байта, int4 доводит её до 28, а следующая строка начинается на границе 8 байт: 28 округляется до 32, как и 24 + 8. Поэтому два int4 тоже помещаются в 32 байта, а два int8 дают 40 и таблицу на 22 % больше (44 285 952 против 36 249 600). bigint в одиночном ключе бесплатен, а int4 в нём экономит только то, что соседний столбец добирает до границы. w_t и w_v совпадают до байта, w_c вырос в 3,7 раза.
Опыт 2: numeric, float8 и целые копейки
Формат numeric описан в numeric.c (REL_18_6, строки 96–98): число хранится группами по 4 десятичные цифры, на каждую группу один int16.
#define NBASE 10000
#define HALF_NBASE 5000
#define DEC_DIGITS 4 /* decimal digits per NBASE digit */
Размер растёт с числом групп, а не с типом:
CREATE TABLE sn (n numeric);
INSERT INTO sn VALUES (0), (1), (12345678), (1234.56), (123456789012), (0.000001), (1234567890123456789012345678);
SELECT n, pg_column_size(n) AS bytes FROM sn;
вывод
n | bytes
------------------------------+-------
0 | 3
1 | 5
12345678 | 7
1234.56 | 7
123456789012 | 9
0.000001 | 5
1234567890123456789012345678 | 17
(7 rows)
3 байта заголовка плюс по 2 на группу: 12345678 — две группы (7 байт), 28 цифр — семь (17). Цена арифметики: 5 млн строк с суммой в копейках int8, в numeric(12,2) и в float8, параллельные воркеры выключены, каждый запрос запущен три раза:
CREATE TABLE m (cents int8, n numeric(12,2), d float8);
INSERT INTO m SELECT c, c / 100.0, c / 100.0 FROM (SELECT (random() * 1000000)::int8 AS c FROM generate_series(1, 5000000)) s;
VACUUM ANALYZE m;
SET max_parallel_workers_per_gather = 0;
\timing on
SELECT sum(cents) FROM m; -- три раза; затем sum(n) и sum(d)
\timing off
SELECT sum(cents) / 100.0 AS cents, sum(n) AS numeric, sum(d) AS float8 FROM m;
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8 AS float8_eq, 0.1::numeric + 0.2 = 0.3 AS numeric_eq;
вывод (время девяти запусков, порядок: cents, n, d; после — итоги и сравнение)
Time: 114.643 ms
Time: 113.742 ms
Time: 117.186 ms
Time: 225.146 ms
Time: 225.795 ms
Time: 242.948 ms
Time: 134.305 ms
Time: 131.879 ms
Time: 136.424 ms
cents | numeric | float8
----------------------+----------------+-------------------
25000914076.24000000 | 25000914076.24 | 25000914076.23777
(1 row)
float8_eq | numeric_eq
-----------+------------
f | t
(1 row)
Сумма numeric совпала с суммой целых копеек, float8 ушёл в хвост .23777, и 0.1 + 0.2 = 0.3 для float8 ложно. По времени numeric примерно вдвое дольше bigint (медианы 225,8 и 114,6 мс) и в 1,7 раза дольше float8 (134,3 мс). Деньги — numeric(p,s) или целые копейки; float8 годится для измерений, где округление в 16-й цифре не важно.
timestamp и timestamptz: момент или показание часов
timestamp хранит показание часов без зоны, timestamptz приводит значение к UTC при записи и показывает в зоне сессии. Одна и та же строка '2026-10-28 12:00', вставленная из двух зон:
CREATE TABLE ev (session_tz text, ts timestamp, tz timestamptz);
SET TimeZone = 'Europe/Moscow';
INSERT INTO ev VALUES (current_setting('TimeZone'), '2026-10-28 12:00', '2026-10-28 12:00');
SET TimeZone = 'America/New_York';
INSERT INTO ev VALUES (current_setting('TimeZone'), '2026-10-28 12:00', '2026-10-28 12:00');
SET TimeZone = 'UTC';
SELECT * FROM ev;
вывод
session_tz | ts | tz
------------------+---------------------+------------------------
Europe/Moscow | 2026-10-28 12:00:00 | 2026-10-28 09:00:00+00
America/New_York | 2026-10-28 12:00:00 | 2026-10-28 16:00:00+00
(2 rows)
В ts обе строки одинаковы, хотя события разделяют 7 часов, и восстановить, из какой зоны пришла запись, нельзя. tz хранит момент: 09:00 и 16:00 UTC. Размер у timestamptz тот же 8 байт, что у int8 (опыт 1), так что выбор определяется смыслом, а не байтами.
Опыт 3: bigint, uuid v4 и uuid v7 на 10 млн строк
В PostgreSQL 18 появилась uuidv7() (заметки о выпуске); gen_random_uuid() по-прежнему выдаёт v4. Версия видна по первой цифре третьей группы:
SELECT uuidv7(), gen_random_uuid();
вывод
uuidv7 | gen_random_uuid
--------------------------------------+--------------------------------------
01a17fbf-44a1-7b15-acd6-f3ffb9d73c7f | ed764133-8106-4097-9c7a-35bde9c3c1d7
(1 row)
Первые 48 бит v7 — время в миллисекундах, следом 12 бит доли миллисекунды (uuid.c, generate_uuidv7, строки 608–613):
* UUID version 7 consists of a Unix timestamp in milliseconds (48 bits) and
* 74 random bits, excluding the required version and variant bits. To ensure
* monotonicity in scenarios of high-frequency UUID generation, we employ the
* method "Replace Leftmost Random Bits with Increased Clock Precision (Method 3)",
* described in the RFC. This method utilizes 12 bits from the "rand_a" bits
* to store a 1/4096 (or 2^12) fraction of sub-millisecond precision.
Опыт: таблица из одного столбца id с первичным ключом, 10 млн строк за один INSERT ... SELECT, три варианта подряд; после замера таблица удаляется, чтобы данные не превысили 2 ГБ. Скрипт run.sql до вставки делает CHECKPOINT и сбрасывает статистику ввода-вывода, после вставки снимает размер WAL по LSN, счётчики pg_stat_io клиентского бэкенда и pgstatindex из pgstattuple:
\set ON_ERROR_STOP 1
DROP TABLE IF EXISTS k;
CHECKPOINT;
CREATE TABLE k (id :type PRIMARY KEY);
SELECT pg_stat_reset_shared('io'), pg_stat_reset_shared('wal') \gset
SELECT pg_current_wal_lsn() AS l0, clock_timestamp() AS t0 \gset
INSERT INTO k SELECT :expr FROM generate_series(1, :rows) g;
SELECT pg_current_wal_lsn() AS l1, clock_timestamp() AS t1 \gset
SELECT pg_stat_force_next_flush();
SELECT :'kind' AS kind, round(extract(epoch FROM :'t1'::timestamptz - :'t0'::timestamptz)::numeric, 1) AS seconds,
pg_size_pretty(pg_wal_lsn_diff(:'l1', :'l0')) AS wal, (SELECT wal_fpi FROM pg_stat_wal) AS fpi,
(SELECT sum(reads) FROM pg_stat_io WHERE object = 'relation' AND context = 'normal' AND backend_type = 'client backend') AS buf_reads,
(SELECT sum(writes) FROM pg_stat_io WHERE object = 'relation' AND context = 'normal' AND backend_type = 'client backend') AS buf_writes,
pg_size_pretty(pg_table_size('k')) AS heap;
SELECT :'kind' AS kind, pg_size_pretty(index_size::bigint) AS index_size, tree_level, leaf_pages, avg_leaf_density, leaf_fragmentation FROM pgstatindex('k_pkey');
docker exec -i dba-data-types-on-the-scale psql -U postgres -c 'CREATE EXTENSION pgstattuple'
docker exec -i dba-data-types-on-the-scale psql -U postgres -v kind=uuid_v7 -v type=uuid -v expr='uuidv7()' -v rows=10000000 -f - < run.sql
# bigint: -v type=bigint -v expr=g; uuid v4: -v type=uuid -v expr='gen_random_uuid()'
вывод (три прогона, строки сведены в две таблицы; fpi — полностраничные образы в WAL, buf_reads и buf_writes — чтения и записи страниц в shared_buffers клиентским бэкендом)
kind | seconds | wal | fpi | buf_reads | buf_writes | heap
---------+---------+---------+-------+-----------+------------+--------
bigint | 9.8 | 1244 MB | 37 | 8 | 49616 | 346 MB
uuid_v7 | 14.7 | 1409 MB | 39 | 108 | 70435 | 422 MB
uuid_v4 | 68.4 | 2042 MB | 90145 | 3074268 | 3133106 | 422 MB
kind | index_size | tree_level | leaf_pages | avg_leaf_density | leaf_fragmentation
---------+------------+------------+------------+------------------+--------------------
bigint | 214 MB | 2 | 27323 | 90.04 | 0
uuid_v7 | 301 MB | 2 | 38315 | 89.99 | 0
uuid_v4 | 388 MB | 2 | 49468 | 69.78 | 49.81
Три разницы. Ширина. uuid вдвое шире bigint: индекс v7 на 41 % больше (301 против 214 МБ), таблица тоже шире (422 против 346 МБ). Плотность листьев у обоих 90 %: вставки идут в правый край, и страницы там делятся так, что слева остаётся 90 % (как в статье про B-tree). Порядок. v4 ложится в любой лист и делит его пополам: плотность 69,8 %, leaf_fragmentation 49,81 (около половины листьев лежат не в логическом порядке файла), индекс ещё на 29 % больше v7 при тех же 10 млн ключей. Память. Индекс v4 весит 388 МБ при 128 МБ shared_buffers, поэтому почти каждая вставка читает свой лист: 3 074 268 страниц, около 23,5 ГиБ, прошло через буфер, против 108 у v7. Диск при этом был в кеше ОС, иначе разрыв был бы больше. Первая запись страницы после контрольной точки пишет её целиком, и у v4 таких образов 90 145 вместо 39, отсюда 2042 против 1409 МБ WAL (причина — полностраничные записи).
WAL вставки на 10 млн строк (1244–2042 МБ) больше max_wal_size в 1 ГБ, и начало диапазона l0 могло быть переиспользовано раньше, чем его прочтёт pg_walinspect. Поэтому разделения посчитаны на 1 млн строк по WAL между l0 и l1:
CREATE EXTENSION pg_walinspect;
SELECT sum(count) AS page_splits FROM pg_get_wal_stats(:'l0', :'l1', per_record => true)
WHERE "resource_manager/record_type" LIKE 'Btree/SPLIT%';
вывод (три отдельных прогона по 1 млн строк: bigint, uuid_v7, uuid_v4; считаются разделения всех уровней)
page_splits
-------------
2741
page_splits
-------------
3849
page_splits
-------------
4861
Число растёт примерно как число листьев, но не объясняет разрыва во времени: он в чтениях и в образах страниц. В InnoDB таблица и есть дерево по первичному ключу, поэтому там разница сильнее: 1306,4 с у v4 против 22,3 с у v7 на 5 млн строк (кластерный индекс InnoDB). В PostgreSQL таблица остаётся кучей, и v4 бьёт только по индексам.
Что из этого следует
- Одиночный числовой ключ:
bigint. В таблице и индексе он стоит какint4, а потолокint4в 2 147 483 647 не нужно будет обходить. - Размер строки зависит от порядка столбцов, не только от типов. Пары
int4умещаются в границу 8 байт; смешанный порядок сint8добавляет пустоты (замер на миллионе строк — в статье про заголовок строки). varchar(n)иtextвесят одинаково. Лимит ставь там, где он правило предметной области (длина кода страны), не как оптимизацию.char(n)не используй.- Деньги:
numeric(p,s)или целые копейки.float8для денег накапливает хвост (.23777), аnumericвдвое медленнееbigintна сумме. - Момент времени:
timestamptz.timestampгодится для дат календаря и расписаний «в местное время», где зона не важна. - Если нужен
uuidв ключе, это v7. v7 ширеbigintна 41 % в индексе, но v4 ещё на 29 % шире v7 и читает страницу на каждую вставку. Версию v4 оставь для значений, которые не должны угадываться по времени.
Чек-лист
- Найди широкие и случайные ключи:
SELECT indexrelid::regclass, pg_relation_size(indexrelid) FROM pg_index WHERE indexrelid > 16383 ORDER BY 2 DESC LIMIT 10;и тип первого столбца этих индексов. - Для индекса с
uuidv4 запустиpgstatindex('имя_индекса'):avg_leaf_densityоколо 70 и высокаяleaf_fragmentationуказывают на случайные вставки. - Проверь денежные столбцы на
float8иreal:SELECT table_name, column_name FROM information_schema.columns WHERE data_type IN ('double precision','real'); - Найди
timestampбез зоны для моментов событий:SELECT table_name, column_name FROM information_schema.columns WHERE data_type = 'timestamp without time zone'; - Убери контейнер вместе с томом:
docker rm -f -v dba-data-types-on-the-scale.