Client (psql, приложение) → PostgreSQL Server → Database → Schema → Table (строки и столбцы)
Database: изолированная база данных. Один сервер может содержать множество баз.
Schema: пространство имён внутри базы. По умолчанию public. Группирует таблицы, функции, типы.
Table: структурированное хранилище данных: столбцы (типы) + строки (записи).
Index: структура для ускорения поиска. Без индекса выполняется sequential scan (перебор всей таблицы).
Transaction: группа операций, которые выполняются атомарно (всё или ничего).
MVCC: Multi-Version Concurrency Control. Читатели не блокируют писателей и наоборот. Каждая транзакция видит свой снимок данных.
WAL: Write-Ahead Log. Все изменения сначала пишутся в лог, потом на диск. Гарантирует целостность при сбое.
# psql — встроенный клиентpsql -U dev -d myapp -h localhost # подключитьсяpsql "postgresql://dev:dev@localhost:5432/myapp"# connection string# Из Dockerdocker exec -it pg psql -U dev -d myapp
Создание базы и пользователя
CREATEUSER app_user WITH PASSWORD 'secret';
CREATEDATABASE myapp OWNER app_user;
GRANTALLPRIVILEGESONDATABASE myapp TO app_user;
-- Для существующей базы: дать доступ к схеме
\c myapp
GRANTUSAGEONSCHEMApublicTO app_user;
GRANTALLONALL TABLES INSCHEMApublicTO app_user;
ALTERDEFAULTPRIVILEGESINSCHEMApublicGRANTALLON TABLES TO app_user;
serial / bigserial: обёртки над integer / bigint + sequence. В новом коде предпочитай GENERATED ALWAYS AS IDENTITY.
Строки
Тип
Описание
text
строка без ограничения длины (основной тип)
varchar(n)
строка с ограничением длины
char(n)
фиксированная длина (дополняется пробелами)
В PostgreSQL text и varchar одинаковы по производительности. Используй text по умолчанию, varchar(n) только если нужно ограничение длины как бизнес-правило.
Дата и время
Тип
Описание
Пример
timestamp
дата + время без часового пояса
2025-03-15 14:30:00
timestamptz
дата + время с часовым поясом
2025-03-15 14:30:00+03
date
только дата
2025-03-15
time
только время
14:30:00
interval
продолжительность
2 hours 30 minutes
Всегда используй timestamptz вместо timestamp. Это избавляет от проблем с часовыми поясами.
Остальные
Тип
Описание
boolean
true / false / null
uuid
UUID (128-бит, gen_random_uuid())
jsonb
бинарный JSON (индексируемый, основной тип для JSON)
json
текстовый JSON (хранит как есть, нет индексов)
bytea
бинарные данные
inet / cidr
IP-адреса / подсети
int4range, tstzrange и др.
диапазоны
tsvector / tsquery
полнотекстовый поиск
point, line, polygon
геометрия
Массивы
CREATETABLE posts (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY,
tags text[] -- массив строк
);
INSERTINTO posts (tags) VALUES ('{postgres,sql,database}');
INSERTINTO posts (tags) VALUES (ARRAY['go', 'backend']);
SELECT*FROM posts WHERE'postgres'=ANY(tags);
SELECT*FROM posts WHERE tags @> ARRAY['sql']; -- содержит все элементы
DDL (определение структуры)
CREATE TABLE
CREATETABLE users (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY,
email text NOTNULLUNIQUE,
name text NOTNULL,
role text NOTNULLDEFAULT'user'CHECK (roleIN ('user', 'admin', 'moderator')),
is_active boolean NOTNULLDEFAULTtrue,
metadata jsonb DEFAULT'{}',
created_at timestamptz NOTNULLDEFAULT now(),
updated_at timestamptz NOTNULLDEFAULT now()
);
CREATETABLE posts (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY,
author_id bigint NOTNULLREFERENCES users(id) ONDELETECASCADE,
title text NOTNULL,
body text NOTNULLDEFAULT'',
status text NOTNULLDEFAULT'draft'CHECK (status IN ('draft', 'published', 'archived')),
published_at timestamptz,
created_at timestamptz NOTNULLDEFAULT now(),
updated_at timestamptz NOTNULLDEFAULT now()
);
CREATETABLE comments (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY,
post_id bigint NOTNULLREFERENCES posts(id) ONDELETECASCADE,
author_id bigint NOTNULLREFERENCES users(id) ONDELETECASCADE,
body text NOTNULL,
created_at timestamptz NOTNULLDEFAULT now()
);
-- Таблица связи many-to-many
CREATETABLE post_tags (
post_id bigint NOTNULLREFERENCES posts(id) ONDELETECASCADE,
tag_id bigint NOTNULLREFERENCES tags(id) ONDELETECASCADE,
PRIMARYKEY (post_id, tag_id)
);
GENERATED ALWAYS AS IDENTITY: современная замена serial. Не позволяет вставить значение вручную (если не OVERRIDING SYSTEM VALUE). Всегда предпочитай.
ON DELETE CASCADE: при удалении родительской записи удаляются дочерние. Альтернативы:
ON DELETE SET NULL: обнулить FK
ON DELETE RESTRICT: запретить удаление (по умолчанию)
ALTER TABLE
-- Добавить столбец
ALTERTABLE users ADDCOLUMN phone text;
-- Удалить столбец
ALTERTABLE users DROPCOLUMN phone;
-- Переименовать столбец
ALTERTABLE users RENAMECOLUMN name TO full_name;
-- Изменить тип
ALTERTABLE users ALTERCOLUMN name TYPE varchar(255);
-- Добавить NOT NULL
ALTERTABLE users ALTERCOLUMN name SETNOTNULL;
-- Убрать NOT NULL
ALTERTABLE users ALTERCOLUMN name DROPNOTNULL;
-- Добавить значение по умолчанию
ALTERTABLE users ALTERCOLUMNroleSETDEFAULT'user';
-- Добавить constraint
ALTERTABLE users ADDCONSTRAINT users_email_check CHECK (email ~*'^.+@.+\..+$');
-- Удалить constraint
ALTERTABLE users DROPCONSTRAINT users_email_check;
-- Добавить уникальный индекс
ALTERTABLE users ADDCONSTRAINT users_phone_unique UNIQUE (phone);
-- Переименовать таблицу
ALTERTABLE users RENAMETO accounts;
DROP
DROPTABLEIFEXISTS posts CASCADE; -- CASCADE удаляет зависимые объекты (FK, views)
DROPTABLEIFEXISTS posts RESTRICT; -- RESTRICT — ошибка, если есть зависимости (по умолчанию)
Схемы
CREATESCHEMA billing;
CREATETABLE billing.invoices (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY,
amount numeric(12,2) NOTNULL);
-- Путь поиска схем
SET search_path TO billing, public;
SHOW search_path;
DML (работа с данными)
INSERT
-- Одна строка
INSERTINTO users (email, name) VALUES ('alice@example.com', 'Alice');
-- Несколько строк
INSERTINTO users (email, name) VALUES ('bob@example.com', 'Bob'),
('carol@example.com', 'Carol');
-- Вернуть вставленные данные
INSERTINTO users (email, name) VALUES ('dave@example.com', 'Dave')
RETURNING id, email, created_at;
-- INSERT ... ON CONFLICT (upsert)
INSERTINTO users (email, name) VALUES ('alice@example.com', 'Alice Updated')
ON CONFLICT (email) DOUPDATESET name = EXCLUDED.name,
updated_at = now();
-- Игнорировать конфликт
INSERTINTO users (email, name) VALUES ('alice@example.com', 'Alice')
ON CONFLICT (email) DONOTHING;
-- INSERT из SELECT
INSERTINTO archived_users (id, email, name)
SELECT id, email, name FROM users WHERE is_active =false;
UPDATE
-- Простой update
UPDATE users SET name ='Alice Smith'WHERE email ='alice@example.com';
-- Несколько столбцов
UPDATE users SET name ='Alice Smith',
role='admin',
updated_at = now()
WHERE email ='alice@example.com';
-- UPDATE с RETURNING
UPDATE users SET is_active =falseWHERE id =42 RETURNING *;
-- UPDATE из другой таблицы (UPDATE ... FROM)
UPDATE posts SET status ='archived'FROM users
WHERE posts.author_id = users.id AND users.is_active =false;
Всегда используй WHERE в UPDATE/DELETE. Без WHERE обновятся/удалятся все строки.
DELETE
DELETEFROM users WHERE id =42;
DELETEFROM users WHERE is_active =false RETURNING id, email;
-- Удалить всё (медленно — пишет в WAL построчно)
DELETEFROM logs;
-- Очистить таблицу (быстро — не пишет в WAL построчно)
TRUNCATETABLE logs;
TRUNCATETABLE logs RESTARTIDENTITYCASCADE; -- сбросить sequence + каскад
SELECT (выборка данных)
Основы
-- Все столбцы
SELECT*FROM users;
-- Конкретные столбцы
SELECT id, email, name FROM users;
-- Псевдонимы
SELECT u.email, u.name AS full_name FROM users u;
-- Уникальные значения
SELECTDISTINCTroleFROM users;
SELECTDISTINCTON (author_id) *FROM posts ORDERBY author_id, created_at DESC;
-- DISTINCT ON — PostgreSQL-расширение: первая строка для каждого значения author_id
WHERE (фильтрация)
-- Сравнение
SELECT*FROM users WHERErole='admin';
SELECT*FROM users WHERE created_at >='2025-01-01';
SELECT*FROM users WHERE id !=42;
-- Логические операторы
SELECT*FROM users WHERErole='admin'AND is_active =true;
SELECT*FROM users WHERErole='admin'ORrole='moderator';
SELECT*FROM users WHERENOT is_active;
-- IN
SELECT*FROM users WHEREroleIN ('admin', 'moderator');
SELECT*FROM users WHERE id IN (SELECT author_id FROM posts WHERE status ='published');
-- BETWEEN
SELECT*FROM posts WHERE created_at BETWEEN'2025-01-01'AND'2025-12-31';
-- LIKE / ILIKE (регистронезависимый)
SELECT*FROM users WHERE email LIKE'%@gmail.com';
SELECT*FROM users WHERE name ILIKE'%alice%'; -- PostgreSQL: ILIKE = case-insensitive
-- Регулярные выражения
SELECT*FROM users WHERE email ~'^[a-z]+@'; -- POSIX regex
SELECT*FROM users WHERE email ~*'^[a-z]+@'; -- case-insensitive
-- NULL
SELECT*FROM users WHERE phone ISNULL;
SELECT*FROM users WHERE phone ISNOTNULL;
-- EXISTS
SELECT*FROM users u
WHEREEXISTS (SELECT1FROM posts p WHERE p.author_id = u.id);
ORDER BY, LIMIT, OFFSET
SELECT*FROM users ORDERBY created_at DESC;
SELECT*FROM users ORDERBYroleASC, name ASC;
SELECT*FROM users ORDERBY created_at DESCLIMIT10;
SELECT*FROM users ORDERBY id LIMIT10OFFSET20; -- пагинация (стр. 3)
-- Cursor-based пагинация (эффективнее OFFSET для больших данных)
SELECT*FROM users
WHERE id >100-- последний id предыдущей страницы
ORDERBY id
LIMIT10;
-- NULLS FIRST / NULLS LAST
SELECT*FROM posts ORDERBY published_at DESC NULLS LAST;
Агрегация: GROUP BY, HAVING
-- COUNT
SELECTCOUNT(*) FROM users;
SELECTCOUNT(*) FROM users WHERE is_active =true;
SELECTCOUNT(DISTINCTrole) FROM users;
-- GROUP BY
SELECTrole, COUNT(*) AScountFROM users
GROUPBYroleORDERBYcountDESC;
-- Несколько агрегатов
SELECT author_id,
COUNT(*) AS post_count,
MIN(created_at) AS first_post,
MAX(created_at) AS last_post
FROM posts
GROUPBY author_id;
-- HAVING — фильтрация после GROUP BY
SELECT author_id, COUNT(*) AS post_count
FROM posts
GROUPBY author_id
HAVINGCOUNT(*) >=5;
-- Агрегатные функции: COUNT, SUM, AVG, MIN, MAX, ARRAY_AGG, STRING_AGG, BOOL_AND, BOOL_OR
SELECT author_id,
ARRAY_AGG(title ORDERBY created_at) AS titles,
STRING_AGG(title, ', 'ORDERBY created_at) AS titles_str
FROM posts
GROUPBY author_id;
Порядок выполнения SQL
Порядок выполнения SQL
FROM / JOIN — откуда берём данные
WHERE — фильтрация строк
GROUP BY — группировка
HAVING — фильтрация групп
SELECT — выбор столбцов, вычисления
DISTINCT — убрать дубликаты
ORDER BY — сортировка
LIMIT / OFFSET — ограничение результата
Важно: WHERE выполняется до GROUP BY, HAVING выполняется после. В WHERE нельзя использовать агрегаты.
JOIN (объединение таблиц)
-- INNER JOIN — только совпадающие строки из обеих таблиц
SELECT u.name, p.title
FROM users u
JOIN posts p ON p.author_id = u.id;
-- LEFT JOIN — все строки из левой + совпадающие из правой (или NULL)
SELECT u.name, COUNT(p.id) AS post_count
FROM users u
LEFTJOIN posts p ON p.author_id = u.id
GROUPBY u.id, u.name;
-- RIGHT JOIN — все из правой + совпадающие из левой (редко используется)
-- FULL JOIN — все из обеих таблиц
-- Несколько JOIN-ов
SELECT p.title,
u.name AS author,
COUNT(c.id) AS comment_count
FROM posts p
JOIN users u ON u.id = p.author_id
LEFTJOIN comments cONc.post_id = p.id
WHERE p.status ='published'GROUPBY p.id, p.title, u.name
ORDERBY comment_count DESC;
-- CROSS JOIN — декартово произведение (каждый с каждым)
SELECT*FROM sizes CROSSJOIN colors;
-- Self JOIN
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFTJOIN employees m ON m.id = e.manager_id;
-- LATERAL JOIN — подзапрос может ссылаться на предыдущие таблицы
SELECT u.name, latest.title, latest.created_at
FROM users u
LEFTJOINLATERAL (
SELECT title, created_at
FROM posts
WHERE author_id = u.id
ORDERBY created_at DESCLIMIT3) latest ONtrue;
Краткая форма
Полная форма
Описание
JOIN
INNER JOIN
только пересечение
LEFT JOIN
LEFT OUTER JOIN
всё из левой + пересечение
RIGHT JOIN
RIGHT OUTER JOIN
всё из правой + пересечение
FULL JOIN
FULL OUTER JOIN
всё из обеих
CTE: Common Table Expressions (WITH)
-- Именованный подзапрос — улучшает читаемость
WITH active_authors AS (
SELECTDISTINCT author_id
FROM posts
WHERE status ='published')
SELECT u.name, u.email
FROM users u
JOIN active_authors aa ON aa.author_id = u.id;
-- Несколько CTE
WITHpost_stats AS (
SELECT author_id, COUNT(*) AS post_count
FROM posts
GROUPBY author_id
),
comment_stats AS (
SELECT author_id, COUNT(*) AS comment_count
FROM comments
GROUPBY author_id
)
SELECT u.name,
COALESCE(ps.post_count, 0) AS posts,
COALESCE(cs.comment_count, 0) AS comments
FROM users u
LEFTJOIN post_stats ps ON ps.author_id = u.id
LEFTJOIN comment_stats cs ON cs.author_id = u.id
ORDERBY posts DESC;
-- Рекурсивный CTE — иерархии, деревья
WITHRECURSIVE category_tree AS (
-- Базовый случай (корневые категории)
SELECT id, name, parent_id, 0AS depth, name::text AS path
FROM categories
WHERE parent_id ISNULLUNIONALL-- Рекурсивный шаг
SELECTc.id, c.name, c.parent_id, ct.depth +1, ct.path ||' > '||c.name
FROM categories cJOIN category_tree ct ON ct.id =c.parent_id
)
SELECT*FROM category_tree ORDERBY path;
Подзапросы
-- В WHERE
SELECT*FROM users
WHERE id IN (SELECT author_id FROM posts WHERE status ='published');
-- Скалярный подзапрос (возвращает одно значение)
SELECT name,
(SELECTCOUNT(*) FROM posts WHERE author_id = users.id) AS post_count
FROM users;
-- EXISTS (эффективнее IN для больших таблиц)
SELECT*FROM users u
WHEREEXISTS (
SELECT1FROM posts p WHERE p.author_id = u.id AND p.status ='published');
-- NOT EXISTS
SELECT*FROM users u
WHERENOTEXISTS (
SELECT1FROM posts p WHERE p.author_id = u.id
);
-- В FROM
SELECT avg_posts.avg_count
FROM (
SELECT author_id, COUNT(*) AS avg_count
FROM posts
GROUPBY author_id
) avg_posts;
Оконные функции (Window Functions)
Выполняют вычисления по набору строк, связанных с текущей строкой, без группировки:
-- ROW_NUMBER — порядковый номер в окне
SELECT name,
role,
ROW_NUMBER() OVER (ORDERBY created_at) AS row_num
FROM users;
-- Нумерация внутри группы
SELECT author_id,
title,
ROW_NUMBER() OVER (PARTITION BY author_id ORDERBY created_at DESC) AS rn
FROM posts;
-- Получить последний пост каждого автора
SELECT*FROM (
SELECT*,
ROW_NUMBER() OVER (PARTITION BY author_id ORDERBY created_at DESC) AS rn
FROM posts
) sub
WHERE rn =1;
-- RANK и DENSE_RANK
SELECT name,
score,
RANK() OVER (ORDERBY score DESC) AS rank, -- 1, 2, 2, 4 (пропуск)
DENSE_RANK() OVER (ORDERBY score DESC) AS dense -- 1, 2, 2, 3 (без пропуска)
FROM players;
-- Агрегаты как оконные функции
SELECT id,
amount,
SUM(amount) OVER (ORDERBY created_at) AS running_total,
AVG(amount) OVER (ORDERBY created_at ROWSBETWEEN2 PRECEDING ANDCURRENTROW) AS moving_avg_3
FROM payments;
-- LAG / LEAD — предыдущее / следующее значение
SELECT date,
revenue,
LAG(revenue) OVER (ORDERBY date) AS prev_revenue,
revenue - LAG(revenue) OVER (ORDERBY date) AS change
FROM daily_revenue;
-- FIRST_VALUE / LAST_VALUE / NTH_VALUE
SELECT name,
department,
salary,
FIRST_VALUE(name) OVER (PARTITION BY department ORDERBY salary DESC) AS top_earner
FROM employees;
-- Именованное окно (чтобы не повторяться)
SELECT id,
amount,
SUM(amount) OVER w AS running_total,
AVG(amount) OVER w AS running_avg
FROM payments
WINDOW w AS (ORDERBY created_at);
Ключевые слова оконных функций
PARTITION BY — разделить на группы (аналог GROUP BY, но без сворачивания строк)
ORDER BY — порядок внутри окна
ROWS BETWEEN — рамка окна (PRECEDING / CURRENT ROW / FOLLOWING)
Индексы
Создание
-- B-Tree (по умолчанию) — равенство и диапазоны
CREATEINDEX idx_users_email ON users(email);
-- Уникальный индекс
CREATEUNIQUEINDEX idx_users_email_unique ON users(email);
-- Составной индекс (порядок столбцов важен!)
CREATEINDEX idx_posts_author_status ON posts(author_id, status);
-- Работает для: WHERE author_id = 1
-- WHERE author_id = 1 AND status = 'published'
-- НЕ работает для: WHERE status = 'published' (нет author_id)
-- Частичный индекс — только для подмножества строк
CREATEINDEX idx_posts_published ON posts(published_at)
WHERE status ='published';
-- Индекс с включёнными столбцами (Index-Only Scan)
CREATEINDEX idx_posts_author_covering ON posts(author_id) INCLUDE (title, status);
-- GIN — для массивов, JSONB, полнотекстового поиска
CREATEINDEX idx_users_metadata ON users USING gin(metadata);
CREATEINDEX idx_posts_tags ON posts USING gin(tags);
-- GiST — для геометрии, диапазонов, полнотекстового поиска
CREATEINDEX idx_events_period ON events USING gist(period);
-- BRIN — для больших таблиц с естественным порядком (логи, события)
CREATEINDEX idx_logs_created ON logs USING brin(created_at);
-- Индекс по выражению
CREATEINDEX idx_users_email_lower ON users(lower(email));
-- Теперь работает: WHERE lower(email) = 'alice@example.com'
-- Конкурентное создание (не блокирует таблицу, но медленнее)
CREATEINDEX CONCURRENTLY idx_users_name ON users(name);
Управление
-- Список индексов таблицы
\di+ users*-- Размер индекса
SELECT pg_size_pretty(pg_relation_size('idx_users_email'));
-- Удалить
DROPINDEX idx_users_email;
DROPINDEX CONCURRENTLY idx_users_email; -- без блокировки
-- Перестроить
REINDEXINDEX idx_users_email;
REINDEXTABLE users;
-- Проверить использование индексов
SELECT schemaname, tablename, indexname,
idx_scan, -- сколько раз использовался
idx_tup_read, -- сколько строк прочитано через индекс
pg_size_pretty(pg_relation_size(indexrelid)) ASsizeFROM pg_stat_user_indexes
ORDERBY idx_scan ASC; -- неиспользуемые сверху
Когда индексы помогают, а когда нет
Индексируй:
Столбцы в WHERE, JOIN ON, ORDER BY
Foreign key столбцы (ускоряет JOIN и каскадное удаление)
Столбцы с высокой селективностью (много уникальных значений)
Не индексируй:
Маленькие таблицы (seq scan быстрее)
Столбцы с низкой селективностью (boolean, status с 3 значениями), кроме частичных индексов
Таблицы с частыми INSERT/UPDATE (каждый индекс замедляет запись)
Constraints (ограничения целостности)
-- PRIMARY KEY
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY-- UNIQUE
email text UNIQUE-- Или составной:
UNIQUE (user_id, date)
-- NOT NULL
name text NOTNULL-- CHECK
age integer CHECK (age >=0AND age <=150)
status text CHECK (status IN ('active', 'inactive', 'banned'))
-- FOREIGN KEY
author_id bigint REFERENCES users(id) ONDELETECASCADE-- EXCLUDE — запрет пересечений (для диапазонов)
CREATETABLE reservations (
room_id integer,
period tstzrange,
EXCLUDE USING gist (room_id WITH=, period WITH&&)
);
-- Нельзя забронировать одну комнату на пересекающиеся периоды
Транзакции
-- Явная транзакция
BEGIN;
UPDATE accounts SET balance = balance -100WHERE id =1;
UPDATE accounts SET balance = balance +100WHERE id =2;
COMMIT;
-- Откат
BEGIN;
DELETEFROM users WHERE id =42;
ROLLBACK; -- ничего не удалилось
-- SAVEPOINT — частичный откат
BEGIN;
INSERTINTO orders (...) VALUES (...);
SAVEPOINT before_payment;
UPDATE accounts SET balance = balance -100WHERE id =1;
-- Ошибка? Откатить только оплату
ROLLBACKTO before_payment;
-- Продолжить с другой логикой
COMMIT;
Уровни изоляции
-- По умолчанию: READ COMMITTED
-- Каждый оператор видит данные, зафиксированные до его начала
BEGINISOLATIONLEVELREPEATABLEREAD;
-- Транзакция видит снимок данных на момент своего начала
-- Другие транзакции не могут изменить прочитанные данные
COMMIT;
BEGINISOLATIONLEVELSERIALIZABLE;
-- Максимальная изоляция: транзакции выполняются "как будто последовательно"
-- Может вернуть ошибку serialization_failure — нужно повторить транзакцию
COMMIT;
Уровни изоляции транзакций
READ COMMITTED — видишь только зафиксированное (по умолчанию, подходит для 95% случаев)
REPEATABLE READ — снимок на начало транзакции (для отчётов, консистентного чтения)
SERIALIZABLE — полная изоляция (для критичных финансовых операций)
Advisory Locks (программные блокировки)
-- Получить блокировку (не связана с таблицей)
SELECT pg_advisory_lock(42);
-- ... критическая секция ...
SELECT pg_advisory_unlock(42);
-- Попробовать без ожидания
SELECT pg_try_advisory_lock(42); -- true/false
JSONB
-- Создание
CREATETABLE events (
id bigint GENERATED ALWAYS ASIDENTITYPRIMARYKEY,
type text NOTNULL,
payload jsonb NOTNULLDEFAULT'{}');
INSERTINTO events (type, payload) VALUES ('user.signup', '{"email": "alice@example.com", "source": "google"}'),
('order.placed', '{"order_id": 123, "items": [{"sku": "A1", "qty": 2}], "total": 49.99}');
-- Доступ к полям
SELECT payload->'email'FROM events; -- jsonb: "alice@example.com"
SELECT payload->>'email'FROM events; -- text: alice@example.com
SELECT payload->'items'->0->>'sku'FROM events; -- вложенный доступ: A1
SELECT payload#>>'{items,0,sku}'FROM events; -- то же через путь
-- Фильтрация
SELECT*FROM events WHERE payload->>'email'='alice@example.com';
SELECT*FROM events WHERE payload @>'{"source": "google"}'; -- содержит
SELECT*FROM events WHERE payload ?'email'; -- ключ существует
SELECT*FROM events WHERE payload ?| ARRAY['email', 'phone']; -- любой ключ существует
SELECT*FROM events WHERE payload ?& ARRAY['email', 'source']; -- все ключи существуют
-- jsonpath (PostgreSQL 12+)
SELECT*FROM events WHERE payload @@'$.total > 40';
SELECT jsonb_path_query(payload, '$.items[*].sku') FROM events;
-- Модификация
UPDATE events SET payload = payload ||'{"verified": true}'WHERE id =1; -- добавить/обновить
UPDATE events SET payload = payload -'source'WHERE id =1; -- удалить ключ
UPDATE events SET payload = jsonb_set(payload, '{email}', '"new@example.com"') WHERE id =1;
-- Агрегация JSON
SELECT jsonb_agg(name) FROM users; -- ["Alice", "Bob", "Carol"]
SELECT jsonb_object_agg(id, name) FROM users; -- {"1": "Alice", "2": "Bob"}
-- Разворачивание JSON
SELECT*FROM jsonb_each('{"a": 1, "b": 2}'); -- key/value пары
SELECT*FROM jsonb_array_elements('[1, 2, 3]'); -- элементы массива
SELECT*FROM jsonb_to_record('{"name": "Alice", "age": 30}') AS t(name text, age int);
-- GIN-индекс для JSONB
CREATEINDEX idx_events_payload ON events USING gin(payload);
-- Ускоряет: @>, ?, ?|, ?&
-- Для jsonpath:
CREATEINDEX idx_events_payload_path ON events USING gin(payload jsonb_path_ops);
-- Ускоряет: @>, @@
Views и Materialized Views
-- View — виртуальная таблица (запрос выполняется каждый раз)
CREATEVIEW active_users ASSELECT id, email, name, created_at
FROM users
WHERE is_active =true;
SELECT*FROM active_users;
DROPVIEW active_users;
-- Materialized View — кеширует результат физически
CREATE MATERIALIZED VIEW post_statistics ASSELECT p.id,
p.title,
u.name AS author,
COUNT(c.id) AS comment_count,
p.created_at
FROM posts p
JOIN users u ON u.id = p.author_id
LEFTJOIN comments cONc.post_id = p.id
WHERE p.status ='published'GROUPBY p.id, p.title, u.name, p.created_at;
-- Обновить данные
REFRESH MATERIALIZED VIEW post_statistics;
REFRESH MATERIALIZED VIEW CONCURRENTLY post_statistics; -- без блокировки (нужен UNIQUE INDEX)
CREATEUNIQUEINDEX idx_post_stats_id ON post_statistics(id);
-- Когда использовать:
-- View — когда нужен удобный алиас для сложного запроса, данные всегда актуальны
-- Materialized View — для тяжёлых агрегаций, дашбордов, отчётов. Данные "на момент последнего REFRESH"
Полнотекстовый поиск
-- Базовый поиск
SELECT*FROM posts
WHERE to_tsvector('russian', title ||' '|| body) @@ to_tsquery('russian', 'postgres & индекс');
-- Добавить tsvector-столбец для производительности
ALTERTABLE posts ADDCOLUMN search_vector tsvector
GENERATED ALWAYS AS (to_tsvector('russian', coalesce(title,'') ||' '|| coalesce(body,''))) STORED;
CREATEINDEX idx_posts_search ON posts USING gin(search_vector);
-- Поиск с рангом
SELECT title,
ts_rank(search_vector, query) AS rank
FROM posts, to_tsquery('russian', 'postgres | sql') query
WHERE search_vector @@ query
ORDERBY rank DESC;
-- websearch_to_tsquery — парсит "человеческий" запрос
SELECT*FROM posts
WHERE search_vector @@ websearch_to_tsquery('russian', 'postgres индексы -партиции');
-- "postgres" AND "индексы" AND NOT "партиции"
EXPLAIN (анализ запросов)
-- План выполнения (оценочный)
EXPLAINSELECT*FROM users WHERE email ='alice@example.com';
-- Реальный план с метриками (ВЫПОЛНЯЕТ запрос!)
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT*FROM users WHERE email ='alice@example.com';
Чтение плана
Index Scan using idx_users_email on users (cost=0.29..8.30 rows=1 width=128) (actual time=0.015..0.016 rows=1 loops=1)
Index Cond: (email = 'alice@example.com'::text)
Buffers: shared hit=3
Planning Time: 0.080 ms
Execution Time: 0.035 ms
cost: оценочная стоимость (startup..total), в условных единицах
rows: оценка количества строк
actual time: реальное время в мс
Buffers: shared hit: страницы из кеша, read: с диска
loops: сколько раз выполнялся узел
Типы сканирования
Тип
Что значит
Seq Scan
Полный перебор таблицы (нет подходящего индекса или таблица маленькая)
Index Scan
Поиск по индексу + чтение данных из таблицы
Index Only Scan
Все данные из индекса (идеал, если покрывающий индекс)
Bitmap Index Scan
Индекс → битовая карта → чтение таблицы (много строк)
Nested Loop
Для каждой строки левой таблицы выполняется поиск в правой
Hash Join
Построить хеш из меньшей таблицы, искать в нём
Merge Join
Обе таблицы отсортированы, выполняется слияние
Красные флаги в EXPLAIN
Seq Scan на большой таблице с WHERE → нужен индекс
rows=1 в оценке, а actual rows=100000 → устаревшая статистика, запусти ANALYZE tablename
Sort Method: external merge → не хватает work_mem, сортировка на диске
Nested Loop с большим loops=100000 → возможно нужен Hash/Merge Join
-- Обновить статистику
ANALYZE users;
ANALYZE; -- вся база
Производительность
Настройки (postgresql.conf)
# Памятьshared_buffers='256MB' # 25% RAM для выделенного сервераeffective_cache_size='768MB' # 75% RAM (подсказка планировщику)work_mem='16MB' # для сортировок/хешей (на операцию!)maintenance_work_mem='128MB' # для VACUUM, CREATE INDEX# WALwal_buffers='16MB'# Планировщикrandom_page_cost=1.1 # для SSD (по умолчанию 4.0 — для HDD)effective_io_concurrency=200 # для SSD# Параллельные запросыmax_parallel_workers_per_gather=2max_parallel_workers=8
Полезные запросы для мониторинга
-- Размер таблиц
SELECT tablename,
pg_size_pretty(pg_total_relation_size(schemaname ||'.'|| tablename)) AS total,
pg_size_pretty(pg_relation_size(schemaname ||'.'|| tablename)) AS table_only,
pg_size_pretty(pg_total_relation_size(schemaname ||'.'|| tablename) - pg_relation_size(schemaname ||'.'|| tablename)) AS indexes
FROM pg_tables
WHERE schemaname ='public'ORDERBY pg_total_relation_size(schemaname ||'.'|| tablename) DESC;
-- Самые медленные запросы (нужно расширение pg_stat_statements)
CREATE EXTENSION IFNOTEXISTS pg_stat_statements;
SELECT calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS avg_ms,
rows,
query
FROM pg_stat_statements
ORDERBY total_exec_time DESCLIMIT20;
-- Активные запросы
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHEREstate!='idle'ORDERBY duration DESC;
-- Блокировки
SELECT blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid
JOIN pg_locks kl ON kl.locktype = bl.locktype
AND kl.relation = bl.relation
AND kl.pid != bl.pid
ANDNOT bl.grantedJOIN pg_stat_activity blocking ON blocking.pid = kl.pid;
-- Убить зависший запрос
SELECT pg_cancel_backend(pid); -- мягко (отмена запроса)
SELECT pg_terminate_backend(pid); -- жёстко (убить соединение)
-- Мёртвые строки (нужен VACUUM)
SELECT relname,
n_dead_tup,
n_live_tup,
round(n_dead_tup::numeric / greatest(n_live_tup, 1) *100, 2) AS dead_pct,
last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup >1000ORDERBY n_dead_tup DESC;
VACUUM
-- Ручной VACUUM (обычно autovacuum справляется)
VACUUM users; -- освобождает место для переиспользования
VACUUMFULL users; -- перестраивает таблицу (блокирует!) — только при крайней необходимости
VACUUMANALYZE users; -- VACUUM + обновить статистику
Миграции
Безопасные операции (не блокируют таблицу)
-- Добавить столбец (без NOT NULL и без DEFAULT с выражением)
ALTERTABLE users ADDCOLUMN phone text;
-- Добавить столбец с DEFAULT (PostgreSQL 11+ — мгновенно)
ALTERTABLE users ADDCOLUMN is_verified boolean NOTNULLDEFAULTfalse;
-- Создать индекс конкурентно
CREATEINDEX CONCURRENTLY idx_users_phone ON users(phone);
-- Добавить CHECK как NOT VALID, затем VALIDATE отдельно
ALTERTABLE users ADDCONSTRAINT users_phone_check CHECK (phone ~'^\+\d+$') NOTVALID;
ALTERTABLE users VALIDATE CONSTRAINT users_phone_check; -- проверяет существующие строки
-- Добавить FK как NOT VALID, затем VALIDATE
ALTERTABLE posts ADDCONSTRAINT posts_author_fk FOREIGNKEY (author_id) REFERENCES users(id) NOTVALID;
ALTERTABLE posts VALIDATE CONSTRAINT posts_author_fk;
Опасные операции (могут заблокировать)
-- ALTER TABLE ... ALTER COLUMN TYPE — перезаписывает таблицу (при изменении типа с конверсией)
-- Решение: создать новый столбец, мигрировать данные, переименовать
-- ДОБАВИТЬ NOT NULL на существующий столбец — сканирует всю таблицу
-- Решение: сначала CHECK NOT VALID, потом VALIDATE
ALTERTABLE users ADDCONSTRAINT users_phone_not_null CHECK (phone ISNOTNULL) NOTVALID;
ALTERTABLE users VALIDATE CONSTRAINT users_phone_not_null;
-- Затем можно: ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
-- И убрать CHECK: ALTER TABLE users DROP CONSTRAINT users_phone_not_null;
-- Установить таймаут на блокировку (чтобы не ждать вечно)
SET lock_timeout ='5s';
Полезные функции и выражения
Условные выражения
-- CASE
SELECT name,
CASEroleWHEN'admin'THEN'Администратор'WHEN'moderator'THEN'Модератор'ELSE'Пользователь'ENDAS role_name
FROM users;
-- CASE с условиями
SELECT title,
CASEWHEN views >10000THEN'viral'WHEN views >1000THEN'popular'ELSE'regular'ENDAS popularity
FROM posts;
-- COALESCE — первое не-NULL значение
SELECT COALESCE(phone, email, 'нет контакта') AS contact FROM users;
-- NULLIF — возвращает NULL если значения равны
SELECTNULLIF(status, 'unknown') FROM users; -- 'unknown' → NULL
-- GREATEST / LEAST
SELECT GREATEST(a, b, c) FROM numbers;
SELECT now(), -- текущее время с TZ
current_date, -- текущая дата
current_timestamp, -- = now()
-- Извлечение
extract(YEARFROM now()), -- 2025
extract(MONTHFROM now()), -- 3
extract(DOW FROM now()), -- день недели (0=вс)
date_part('hour', now()), -- час
-- Арифметика
now() + interval '1 day',
now() - interval '2 hours 30 minutes',
'2025-12-31'::date -'2025-01-01'::date, -- 364 (дни)
age(now(), '1990-05-15'::timestamptz), -- интервал: 34 years 10 mons ...
-- Форматирование
to_char(now(), 'DD.MM.YYYY HH24:MI'), -- 15.03.2025 14:30
to_char(now(), 'YYYY-MM-DD'), -- 2025-03-15
-- Округление
date_trunc('month', now()), -- начало текущего месяца
date_trunc('hour', now()), -- начало текущего часа
-- Генерация серий
generate_series('2025-01-01'::date, '2025-12-31'::date, '1 month') AS month_start;
Генерация данных
-- Серия чисел
SELECT generate_series(1, 10);
SELECT generate_series(1, 100, 5); -- с шагом 5
-- UUID
SELECT gen_random_uuid();
-- Случайные данные
SELECT random(), -- 0.0 .. 1.0
floor(random() *100)::int, -- 0 .. 99
md5(random()::text); -- случайная строка
Паттерны
Мягкое удаление (Soft Delete)
ALTERTABLE users ADDCOLUMN deleted_at timestamptz;
-- "Удаление"
UPDATE users SET deleted_at = now() WHERE id =42;
-- Запросы — только активные
CREATEVIEW active_users ASSELECT*FROM users WHERE deleted_at ISNULL;
-- Частичный индекс для активных записей
CREATEINDEX idx_users_active ON users(email) WHERE deleted_at ISNULL;
Пагинация курсором
-- Первая страница
SELECT id, title, created_at
FROM posts
WHERE status ='published'ORDERBY created_at DESC, id DESCLIMIT20;
-- Следующая страница (после last_created_at, last_id)
SELECT id, title, created_at
FROM posts
WHERE status ='published'AND (created_at, id) < ($last_created_at, $last_id)
ORDERBY created_at DESC, id DESCLIMIT20;
-- Живёт до конца сессии
CREATE TEMP TABLE tmp_import (
email text,
name text
);
COPY tmp_import FROM'/path/to/data.csv'WITH (FORMAT csv, HEADER);
INSERTINTO users (email, name)
SELECT email, name FROM tmp_import
ON CONFLICT (email) DONOTHING;
Партиционирование
-- Партиционирование по диапазону дат
CREATETABLE events (
id bigint GENERATED ALWAYS ASIDENTITY,
type text NOTNULL,
payload jsonb,
created_at timestamptz NOTNULLDEFAULT now()
) PARTITION BY RANGE (created_at);
CREATETABLE events_2025_q1 PARTITION OF events
FORVALUESFROM ('2025-01-01') TO ('2025-04-01');
CREATETABLE events_2025_q2 PARTITION OF events
FORVALUESFROM ('2025-04-01') TO ('2025-07-01');
-- ...
-- Индекс создаётся на родительской таблице — автоматически на каждой партиции
CREATEINDEX idx_events_type ON events(type);
COPY (импорт и экспорт данных)
COPY: самый быстрый способ загрузить/выгрузить данные в PostgreSQL. Работает напрямую с сервером, минуя SQL-парсер построчно.
COPY vs \copy
COPY (серверная) — читает/пишет файлы на сервере (нужны права суперпользователя)
\copy (клиентская) — читает/пишет файлы на клиенте (через psql, без спец. прав)
Экспорт из таблицы в файл
-- CSV с заголовками
COPY users TO'/tmp/users.csv'WITH (FORMAT csv, HEADER);
-- Через psql (клиентская сторона — файл на локальной машине)
\copy users TO'./users.csv'WITH (FORMAT csv, HEADER)
-- Только определённые столбцы
\copy users (id, email, name) TO'./users.csv'WITH (FORMAT csv, HEADER)
-- С условием (через подзапрос)
\copy (SELECT id, email, name FROM users WHERE is_active =true) TO'./active_users.csv'WITH (FORMAT csv, HEADER)
-- TSV (tab-separated) — формат по умолчанию
\copy users TO'./users.tsv'-- С кастомным разделителем
\copy users TO'./users.txt'WITH (DELIMITER'|')
-- Бинарный формат (быстрее, но непереносимый)
\copy users TO'./users.bin'WITH (FORMAT binary)
-- JSON-подобный вывод (строка на строку)
\copy (SELECT row_to_json(u) FROM users u) TO'./users.jsonl'-- Gzip на лету (через bash pipe)
psql -c"COPY users TO STDOUT WITH (FORMAT csv, HEADER)" myapp | gzip > users.csv.gz
Паттерн: загрузить во временную таблицу → обработать → вставить в целевую:
-- 1. Временная таблица (такая же структура, без constraints)
CREATE TEMP TABLE tmp_import (
email text,
name text,
role text,
created_at text -- текст, чтобы не упасть на невалидных датах
);
-- 2. Загрузить сырые данные
\copy tmp_import FROM'./import.csv'WITH (FORMAT csv, HEADER)
-- 3. Проверить данные
SELECT*FROM tmp_import WHERE email ISNULLOR email ='';
SELECT*FROM tmp_import WHERE created_at !~'^\d{4}-\d{2}-\d{2}';
-- 4. Вставить с преобразованием и дедупликацией
INSERTINTO users (email, name, role, created_at)
SELECTlower(trim(email)),
trim(name),
COALESCE(NULLIF(trim(role), ''), 'user'),
created_at::timestamptz
FROM tmp_import
WHERE email ISNOTNULLAND email !=''ON CONFLICT (email) DOUPDATESET name = EXCLUDED.name,
updated_at = now();
-- 5. Очистить
DROPTABLE tmp_import;
Опции COPY
Опция
Описание
Пример
FORMAT
Формат: csv, text (по умолчанию), binary
FORMAT csv
HEADER
Первая строка (заголовки)
HEADER или HEADER true
DELIMITER
Разделитель полей
DELIMITER ','
NULL
Строковое представление NULL
NULL ''
QUOTE
Символ кавычек
QUOTE '"'
ESCAPE
Символ экранирования
ESCAPE '\'
ENCODING
Кодировка файла
ENCODING 'UTF8'
FORCE_NULL
Преобразовать пустые строки в NULL для указанных столбцов
FORCE_NULL (phone, address)
Производительность COPY
COPY на порядки быстрее, чем INSERT. Для 1М строк:
Метод
Время (1М строк)
INSERT построчный
~5-10 минут
INSERT multi-row (1000)
~20-40 секунд
COPY
~2-5 секунд
Ускорение массовой загрузки:
-- 1. Убрать индексы перед загрузкой, пересоздать после
DROPINDEX idx_users_email;
\copy users FROM'./huge_file.csv'WITH (FORMAT csv, HEADER)
CREATEINDEX idx_users_email ON users(email);
-- 2. Отключить триггеры (нужны права суперпользователя)
ALTERTABLE users DISABLE TRIGGERALL;
\copy users FROM'./huge_file.csv'WITH (FORMAT csv, HEADER)
ALTERTABLE users ENABLE TRIGGERALL;
-- 3. Увеличить maintenance_work_mem для создания индексов
SET maintenance_work_mem ='512MB';
-- 4. Для начальной загрузки — отключить WAL-архивирование (если можно)
-- и установить:
SET wal_level = minimal; -- требует перезагрузки
-- или использовать UNLOGGED таблицы (данные теряются при crash!)
CREATE UNLOGGED TABLE tmp_load (...);
Экспорт между базами
# Перенос таблицы между базами через pipepg_dump -U dev -t users --data-only -Fc source_db | pg_restore -U dev -d target_db
# Через COPY + pipe (без промежуточного файла)psql -U dev source_db -c "COPY users TO STDOUT WITH (FORMAT csv, HEADER)" | \
psql -U dev target_db -c "\copy users FROM STDIN WITH (FORMAT csv, HEADER)"# Перенос с трансформациейpsql -U dev source_db -c "COPY (SELECT id, email, name FROM users WHERE is_active) TO STDOUT WITH (FORMAT csv)" | \
psql -U dev target_db -c "\copy active_users (id, email, name) FROM STDIN WITH (FORMAT csv)"
Работа с внешними форматами
# JSON → PostgreSQLcat data.json | jq -r '.[] | [.email, .name, .role] | @csv' | \
psql -c "\copy users (email, name, role) FROM STDIN WITH (FORMAT csv)" myapp
# PostgreSQL → JSONpsql -c "COPY (SELECT row_to_json(t) FROM (SELECT id, email, name FROM users) t) TO STDOUT" myapp > users.jsonl
# Excel (через csv) — сохранить xlsx как csv, затем:\copy users FROM './export.csv' WITH (FORMAT csv, HEADER, ENCODING 'UTF8')# Если CSV из Excel на Windows — возможно нужна кодировка:\copy users FROM './export.csv' WITH (FORMAT csv, HEADER, ENCODING 'WIN1251')# Parquet / Arrow — через расширение pg_parquet или внешние инструменты (DuckDB):# duckdb -c "COPY (SELECT * FROM read_parquet('data.parquet')) TO 'data.csv' (HEADER, DELIMITER ',')"# затем \copy ... FROM './data.csv'
COPY в приложениях
# Python + psycopgimport psycopg
with psycopg.connect("postgresql://dev:dev@localhost/myapp") as conn:
# Экспортwith conn.cursor().copy("COPY users (email, name) TO STDOUT (FORMAT csv)") as copy:
with open("users.csv", "wb") as f:
for data in copy:
f.write(data)
# Импортwith conn.cursor().copy("COPY users (email, name) FROM STDIN (FORMAT csv)") as copy:
with open("users.csv", "r") as f:
while data := f.read(8192):
copy.write(data.encode())
Возвращают значение. Можно использовать в SELECT, WHERE, DEFAULT и т.д.
-- Простая функция
CREATEORREPLACEFUNCTION full_name(first text, last text)
RETURNS text
LANGUAGEsql-- чистый SQL (без блоков BEGIN/END)
IMMUTABLE-- результат зависит только от аргументов
AS$$SELECTfirst||' '||last;
$$;
SELECT full_name('Alice', 'Smith'); -- Alice Smith
-- PL/pgSQL функция с логикой
CREATEORREPLACEFUNCTION get_user_role(user_id bigint)
RETURNS text
LANGUAGE plpgsql
STABLE-- не модифицирует данные, результат стабилен в рамках запроса
AS$$DECLAREresult text;
BEGINSELECTroleINTOresultFROM users WHERE id = user_id;
IFNOTFOUNDTHEN RAISE EXCEPTION'User % not found', user_id;
ENDIF;
RETURNresult;
END;
$$;
Атрибуты волатильности
Атрибуты волатильности функций
IMMUTABLE — всегда одинаковый результат для одних и тех же аргументов (lower(), math). Можно использовать в индексах. Планировщик кеширует результат.
STABLE — результат не меняется в рамках одного SQL-оператора (чтение из таблиц). Нельзя использовать в индексах.
VOLATILE — результат может меняться при каждом вызове (random(), now(), INSERT). По умолчанию. Планировщик не оптимизирует.
Указывай всегда, это влияет на производительность. Неверный атрибут приведёт к багам или неоптимальным планам.
Переменные и типы
CREATEORREPLACEFUNCTION example_vars()
RETURNS void
LANGUAGE plpgsql
AS$$DECLARE counter integer :=0;
user_email text;
user_rec users%ROWTYPE; -- строка таблицы users (все столбцы)
user_name users.name%TYPE; -- тип конкретного столбца
custom_rec record; -- произвольная структура
arr integer[] := ARRAY[1, 2, 3];
BEGIN-- Присваивание
counter := counter +1;
-- SELECT INTO — одна строка
SELECT email, name INTO user_email, user_name
FROM users WHERE id =1;
-- Целая строка
SELECT*INTO user_rec FROM users WHERE id =1;
RAISE NOTICE 'User: %, email: %', user_rec.name, user_rec.email;
-- record — структура определяется при присваивании
SELECT id, email INTO custom_rec FROM users WHERE id =1;
RAISE NOTICE 'Email: %', custom_rec.email;
END;
$$;
Условия
-- IF / ELSIF / ELSE
IF balance <0THEN RAISE EXCEPTION'Negative balance';
ELSIF balance =0THEN status :='empty';
ELSE status :='active';
ENDIF;
-- CASE
CASEroleWHEN'admin'THEN access_level :=100;
WHEN'moderator'THEN access_level :=50;
ELSE access_level :=10;
ENDCASE;
Циклы
-- Простой цикл
LOOP
counter := counter +1;
EXIT WHEN counter >=10; -- break
CONTINUEWHEN counter =5; -- skip
END LOOP;
-- WHILE
WHILE counter <10 LOOP
counter := counter +1;
END LOOP;
-- FOR (числовой)
FOR i IN1..10 LOOP
RAISE NOTICE 'i = %', i;
END LOOP;
FOR i IN REVERSE 10..1BY2 LOOP -- 10, 8, 6, 4, 2
RAISE NOTICE 'i = %', i;
END LOOP;
-- FOR по запросу
FOR rec INSELECT id, email FROM users WHERE is_active LOOP
RAISE NOTICE 'User: % %', rec.id, rec.email;
END LOOP;
-- FOREACH по массиву
FOREACH item IN ARRAY arr LOOP
RAISE NOTICE 'item = %', item;
END LOOP;
Возврат множества строк
-- RETURNS SETOF — возвращает набор строк
CREATEORREPLACEFUNCTION active_users_by_role(target_role text)
RETURNSSETOF users -- тип = строка таблицы users
LANGUAGE plpgsql
STABLEAS$$BEGINRETURN QUERY
SELECT*FROM users
WHERErole= target_role AND is_active =trueORDERBY created_at DESC;
IFNOTFOUNDTHEN RAISE NOTICE 'No users found for role %', target_role;
ENDIF;
END;
$$;
SELECT*FROM active_users_by_role('admin');
-- RETURNS TABLE — определить структуру результата
CREATEORREPLACEFUNCTION user_stats()
RETURNSTABLE (
user_id bigint,
user_name text,
post_count bigint,
last_post_at timestamptz
)
LANGUAGE plpgsql
STABLEAS$$BEGINRETURN QUERY
SELECT u.id,
u.name,
COUNT(p.id),
MAX(p.created_at)
FROM users u
LEFTJOIN posts p ON p.author_id = u.id
GROUPBY u.id, u.name;
END;
$$;
SELECT*FROM user_stats() WHERE post_count >5;
-- OUT-параметры (альтернатива RETURNS TABLE)
CREATEORREPLACEFUNCTION get_user_info(
IN p_user_id bigint,
OUT p_email text,
OUT p_name text,
OUT p_post_count bigint
)
LANGUAGE plpgsql
STABLEAS$$BEGINSELECT u.email, u.name, COUNT(p.id)
INTO p_email, p_name, p_post_count
FROM users u
LEFTJOIN posts p ON p.author_id = u.id
WHERE u.id = p_user_id
GROUPBY u.email, u.name;
END;
$$;
SELECT*FROM get_user_info(42);
Процедуры (PROCEDURE)
Не возвращают значение. Могут управлять транзакциями (COMMIT/ROLLBACK внутри).
CREATEORREPLACEPROCEDURE transfer_funds(
from_id bigint,
to_id bigint,
amount numeric
)
LANGUAGE plpgsql
AS$$DECLARE current_balance numeric;
BEGIN-- Проверить баланс
SELECT balance INTO current_balance FROM accounts WHERE id = from_id FORUPDATE;
IF current_balance ISNULLTHEN RAISE EXCEPTION'Account % not found', from_id;
ENDIF;
IF current_balance < amount THEN RAISE EXCEPTION'Insufficient funds: have %, need %', current_balance, amount;
ENDIF;
-- Перевод
UPDATE accounts SET balance = balance - amount WHERE id = from_id;
UPDATE accounts SET balance = balance + amount WHERE id = to_id;
-- Лог
INSERTINTO transactions (from_account, to_account, amount)
VALUES (from_id, to_id, amount);
END;
$$;
CALL transfer_funds(1, 2, 100.00);
-- Управление транзакциями внутри процедуры
CREATEORREPLACEPROCEDURE batch_process()
LANGUAGE plpgsql
AS$$DECLARE rec record;
batch_count integer :=0;
BEGINFOR rec INSELECT id FROM tasks WHERE status ='pending' LOOP
UPDATE tasks SET status ='done'WHERE id = rec.id;
batch_count := batch_count +1;
-- Коммитить каждые 1000 строк (чтобы не держать длинную транзакцию)
IF batch_count %1000=0THENCOMMIT;
RAISE NOTICE 'Processed % rows', batch_count;
ENDIF;
END LOOP;
COMMIT;
END;
$$;
Обработка ошибок
CREATEORREPLACEFUNCTION safe_divide(a numeric, b numeric)
RETURNS numeric
LANGUAGE plpgsql
IMMUTABLEAS$$BEGINRETURN a / b;
EXCEPTIONWHEN division_by_zero THENRETURNNULL;
WHEN numeric_value_out_of_range THEN RAISE WARNING 'Overflow in division % / %', a, b;
RETURNNULL;
END;
$$;
-- Полный шаблон обработки ошибок
BEGIN-- ... операции ...
EXCEPTIONWHEN unique_violation THEN RAISE NOTICE 'Duplicate key';
WHEN foreign_key_violation THEN RAISE NOTICE 'FK violation';
WHEN check_violation THEN RAISE NOTICE 'Check failed';
WHEN OTHERS THEN RAISE WARNING 'Unexpected error: % %', SQLSTATE, SQLERRM;
-- SQLSTATE — код ошибки (23505, 23503, ...)
-- SQLERRM — текст ошибки
END;
-- RAISE — уровни сообщений
RAISE DEBUG 'Debug info: %', var; -- не показывается по умолчанию
RAISE NOTICE 'Info: %', var; -- информационное сообщение
RAISE WARNING 'Warning: %', var; -- предупреждение
RAISE EXCEPTION'Error: %', var; -- прерывает выполнение
RAISE EXCEPTION'Not found'USING ERRCODE ='P0002', -- кастомный код ошибки
HINT ='Check user_id';
-- Список функций
\df
\df+ my_function -- детали
-- Показать исходный код
SELECT pg_get_functiondef(oid) FROM pg_proc WHERE proname ='my_function';
-- Удалить
DROPFUNCTION full_name(text, text); -- указать аргументы (перегрузка!)
DROPPROCEDURE transfer_funds(bigint, bigint, numeric);
DROPFUNCTIONIFEXISTS old_function;
-- Удалить триггер
DROPTRIGGER trg_users_updated_at ON users;
-- Изменить владельца / схему
ALTERFUNCTION my_function(text) OWNERTO app_user;
ALTERFUNCTION my_function(text) SETSCHEMA billing;
Бэкап и восстановление
# Дамп одной базы (SQL)pg_dump -U dev myapp > myapp.sql
pg_dump -U dev -Fc myapp > myapp.dump # custom format (сжатый, для pg_restore)# Только данные / только схемаpg_dump -U dev --data-only myapp > data.sql
pg_dump -U dev --schema-only myapp > schema.sql
# Только конкретные таблицыpg_dump -U dev -t users -t posts myapp > tables.sql
# Дамп всего кластера (все базы + роли)pg_dumpall -U dev > cluster.sql
# Восстановлениеpsql -U dev myapp < myapp.sql # из SQLpg_restore -U dev -d myapp myapp.dump # из custom formatpg_restore -U dev -d myapp --clean myapp.dump # удалить объекты перед восстановлением
Расширения
-- Список доступных расширений
SELECT*FROM pg_available_extensions ORDERBY name;
-- Установить
CREATE EXTENSION IFNOTEXISTS pg_stat_statements; -- статистика запросов
CREATE EXTENSION IFNOTEXISTS pgcrypto; -- криптография
CREATE EXTENSION IFNOTEXISTS pg_trgm; -- триграммы (нечёткий поиск)
CREATE EXTENSION IFNOTEXISTS uuid-ossp; -- UUID (или gen_random_uuid() без расширения)
CREATE EXTENSION IFNOTEXISTS citext; -- case-insensitive text
CREATE EXTENSION IFNOTEXISTS hstore; -- key-value пары
CREATE EXTENSION IFNOTEXISTS tablefunc; -- crosstab (сводные таблицы)
-- pg_trgm — поиск похожих строк
CREATEINDEX idx_users_name_trgm ON users USING gin(name gin_trgm_ops);
SELECT*FROM users WHERE name %'Alise'; -- нечёткое совпадение
SELECT*FROM users ORDERBY similarity(name, 'Alise') DESCLIMIT5;
Частые проблемы
Запрос медленный, что делать:
-- 1. Посмотреть план
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- 2. Если Seq Scan — нужен индекс
CREATEINDEX CONCURRENTLY ...;
-- 3. Если rows estimate далёк от actual rows — обновить статистику
ANALYZE tablename;
-- 4. Если сортировка на диске (external merge)
SET work_mem ='64MB'; -- для текущей сессии
Deadlock:
ERROR: deadlock detected
Две транзакции заблокировали друг друга. PostgreSQL автоматически убивает одну. Решения:
Обновлять строки в одинаковом порядке (ORDER BY id)
Короткие транзакции
SELECT ... FOR UPDATE SKIP LOCKED для очередей
Connection limit exceeded:
SHOW max_connections; -- по умолчанию 100
SELECTcount(*) FROM pg_stat_activity; -- текущие подключения
Решение: использовать пул соединений (PgBouncer, встроенный пул в приложении).
Таблица раздулась (bloat) после массовых UPDATE/DELETE:
-- Проверить
SELECT n_dead_tup, n_live_tup FROM pg_stat_user_tables WHERE relname ='users';
-- Обычный VACUUM (освобождает место для переиспользования)
VACUUM users;
-- VACUUM FULL (перестраивает таблицу, блокирует!) — крайний случай
VACUUMFULL users;
-- Лучше: настроить autovacuum агрессивнее для горячих таблиц
ALTERTABLE users SET (autovacuum_vacuum_scale_factor =0.01);
Encoding / locale:
SHOW server_encoding; -- UTF8
SHOW lc_collate; -- сортировка
-- Если нужна case-insensitive сортировка и сравнение:
CREATECOLLATIONIFNOTEXISTS case_insensitive (provider = icu, locale ='und-u-ks-level2', deterministic=false);
ALTERTABLE users ALTERCOLUMN email TYPE text COLLATE case_insensitive;
Забыл WHERE в UPDATE/DELETE:
-- Если внутри транзакции:
ROLLBACK;
-- Если нет — восстановление из бэкапа
-- Профилактика: всегда оборачивай опасные операции в BEGIN/ROLLBACK
BEGIN;
DELETEFROM users WHERE ...;
-- проверь результат
-- COMMIT; или ROLLBACK;