Как практик, который последние несколько лет проектирует и сопровождает системы обработки данных в нескольких коммерческих проектах (от логистических платформ до финтех-решений), я сталкиваюсь с курсорами почти ежедневно. Тема эта — не про «теорию», а про реальные боли: как эффективно перебрать миллион записей, не убив базу данных, и когда курсор — зло, а когда — единственный рабочий инструмент.
В этой статье разберу, что такое курсор на самом деле, как его правильно готовить и, что важнее, когда от него лучше отказаться. Материал рассчитан на разработчиков, которые хотят не просто скопировать пример из документации, а понять внутреннюю кухню: trade-offs, грабли и приёмы, проверенные в бою.
Что такое курсор в программировании: базовое определение
Если совсем просто: курсор — это указатель на текущую позицию в наборе данных. Представьте, что вы читаете книгу: закладка показывает, где вы остановились. Вы можете двигаться вперёд, назад, остановиться, перечитать абзац. Курсор в программировании — та же закладка, только для строк базы данных, записей в файле или элементов потока.
- В SQL — это механизм для последовательной обработки строк результата запроса.
- В языках программирования (Python, Java, C#) — абстракция над курсором БД или файловым потоком.
- В UI — курсор мыши, но это уже другая история.
Важно: курсор не хранит все данные в памяти сразу, а работает с ними по мере необходимости, что экономит ресурсы. Но именно эта «ленивость» часто становится причиной недопонимания и ошибок.
Основные характеристики курсора
Чтобы не путаться, нужно чётко понимать четыре базовых свойства любого курсора.
Состояние курсора
Курсор может быть открыт (активен, готов к выборке) или закрыт (ресурсы освобождены). Забыли закрыть — получите утечку соединений или блокировки таблиц. В языках типа Python с with это решается автоматически, но в PL/SQL или T-SQL — зона ответственности разработчика.
Направление движения
Большинство курсоров движутся только вперёд (forward-only). Но есть прокручиваемые (scrollable): можно прыгнуть на любую строку, назад или вперёд. Платите за это памятью и производительностью.
Привязка к набору данных

Курсор всегда привязан к результату конкретного запроса или к открытому файлу. Если изменить данные в таблице после открытия курсора, поведение зависит от типа: чувствительный (sensitive) увидит изменения, нечувствительный (insensitive) — нет.
Возможность обновления
Некоторые курсоры позволяют не только читать, но и изменять данные через UPDATE ... WHERE CURRENT OF cursor. Это удобно для каскадных обновлений, но требует осторожности: легко создать взаимные блокировки.
Типы курсоров в SQL: явные и неявные
В SQL курсоры делятся на два лагеря: явные (программист управляет всем циклом) и неявные (СУБД делает всё сама). Выбор между ними — компромисс между контролем и простотой.
| Характеристика | Явный курсор | Неявный курсор |
|---|---|---|
| Управление | Полный контроль: DECLARE, OPEN, FETCH, CLOSE | Автоматическое: создаётся и закрывается СУБД |
| Производительность | Ниже (накладные расходы на управление) | Выше (оптимизировано для одиночных запросов) |
| Гибкость | Можно обрабатывать по одной строке, прерывать, изменять | Только чтение одной строки или массовая операция |
| Типичное использование | Сложная бизнес-логика, построчная обработка | SELECT INTO, DML-операции |
Явные курсоры: полный контроль
Работа с явным курсором — это ритуал из четырёх шагов. Пропустите хотя бы один — получите ошибку или утечку.
DECLARE CURSOR
Объявляете курсор и привязываете к нему SQL-запрос. В этот момент запрос не выполняется — только компилируется.
DECLARE cur_orders CURSOR FOR SELECT order_id, total FROM orders WHERE status = 'NEW'; OPEN cursor

Открываете курсор — СУБД выполняет запрос и создаёт результирующий набор (но не обязательно загружает все строки в память).
OPEN cur_orders; FETCH INTO variables
Извлекаете очередную строку в переменные. Обычно в цикле.
FETCH cur_orders INTO v_order_id, v_total; CLOSE cursor
Закрываете курсор, освобождая ресурсы.
CLOSE cur_orders; Атрибуты курсора (FOUND, NOTFOUND, ROWCOUNT) помогают контролировать цикл. Например, EXIT WHEN cur_orders%NOTFOUND — стандартный способ выйти из цикла.
Неявные курсоры: автоматизация
Неявные курсоры создаются автоматически для одиночных SELECT ... INTO или DML-операций (INSERT, UPDATE, DELETE). Вы не пишете DECLARE, OPEN, CLOSE — всё делает СУБД.
Пример неявного курсора
SELECT order_id INTO v_order_id FROM orders WHERE order_id = 1001; Если запрос возвращает 0 строк — исключение NO_DATA_FOUND. Если больше одной — TOO_MANY_ROWS. Это ограничение: неявный курсор рассчитан ровно на одну строку.
Атрибуты неявного курсора

SQL%FOUND, SQL%NOTFOUND, SQL%ROWCOUNT — работают так же, как у явного, но относятся к последней выполненной DML-операции.
Частая ошибка: использовать неявный курсор в цикле для массовой обработки. Каждый вызов SELECT INTO создаёт новый неявный курсор, что приводит к огромным накладным расходам. В таких случаях нужен явный курсор или set-based подход.
Курсоры в различных СУБД: сравнение синтаксиса
Хотя концепция курсора универсальна, синтаксис и возможности сильно различаются. Покажу на примерах для четырёх популярных СУБД.
| СУБД | Особенности | Пример объявления |
|---|---|---|
| Oracle | REF CURSOR, BULK COLLECT, FORALL | CURSOR cur IS SELECT ... |
| PostgreSQL | SCROLL, WITH HOLD, курсоры в функциях | DECLARE cur CURSOR FOR SELECT ... |
| MySQL | Только в хранимых процедурах, нет SCROLL | DECLARE cur CURSOR FOR SELECT ... |
| SQL Server | STATIC, DYNAMIC, KEYSET, FAST_FORWARD | DECLARE cur CURSOR FOR SELECT ... |
Важно: синтаксис курсора может сильно отличаться в разных СУБД, всегда обращайтесь к документации. Например, в MySQL нельзя использовать курсор вне хранимой процедуры, а в PostgreSQL можно объявить курсор в любом блоке.
Курсоры в Oracle: REF CURSOR и BULK COLLECT
Oracle — пожалуй, самая развитая платформа для курсоров. Здесь есть REF CURSOR — мощный инструмент для динамических запросов и передачи курсоров между процедурами.
REF CURSOR
Позволяет объявить переменную-курсор и открыть её для любого запроса, даже сформированного динамически.
TYPE refcur IS REF CURSOR; v_cur refcur; v_id NUMBER; BEGIN OPEN v_cur FOR 'SELECT employee_id FROM employees WHERE department_id = :1' USING 50; LOOP FETCH v_cur INTO v_id; EXIT WHEN v_cur%NOTFOUND; -- обработка END LOOP; CLOSE v_cur; END; BULK COLLECT
Массовая выборка — альтернатива построчному FETCH. Загружает все строки в коллекцию (массив) за один проход.
SELECT employee_id BULK COLLECT INTO v_ids FROM employees WHERE department_id = 50; Пример с FORALL

FORALL — массовое выполнение DML для всех элементов коллекции. В связке с BULK COLLECT даёт огромный прирост производительности.
FORALL i IN 1..v_ids.COUNT UPDATE employees SET salary = salary * 1.1 WHERE employee_id = v_ids(i); Курсоры в PostgreSQL: WITH HOLD и SCROLL
PostgreSQL предлагает два интересных расширения: SCROLL (возможность перемещаться назад) и WITH HOLD (курсор живёт после завершения транзакции).
SCROLL cursor
Позволяет использовать FETCH PRIOR, FETCH ABSOLUTE n и другие команды навигации.
DECLARE cur SCROLL CURSOR FOR SELECT * FROM orders; FETCH PRIOR FROM cur; WITH HOLD
Обычно курсор автоматически закрывается при завершении транзакции. WITH HOLD отменяет это поведение — курсор остаётся открытым до явного CLOSE.
DECLARE cur CURSOR WITH HOLD FOR SELECT * FROM orders; Пример
BEGIN; DECLARE cur CURSOR WITH HOLD FOR SELECT * FROM orders WHERE status = 'PENDING'; COMMIT; -- курсор всё ещё открыт! FETCH cur INTO ... CLOSE cur; Курсоры в MySQL: хранимые процедуры

В MySQL курсоры доступны только внутри хранимых процедур (не в функциях). Нет поддержки SCROLL — только forward-only.
Объявление курсора в MySQL
DECLARE cur CURSOR FOR SELECT id, name FROM users WHERE active = 1; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; Пример хранимой процедуры
CREATE PROCEDURE process_users() BEGIN DECLARE done INT DEFAULT FALSE; DECLARE v_id INT; DECLARE v_name VARCHAR(100); DECLARE cur CURSOR FOR SELECT id, name FROM users WHERE active = 1; DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; OPEN cur; read_loop: LOOP FETCH cur INTO v_id, v_name; IF done THEN LEAVE read_loop; END IF; -- обработка строки END LOOP; CLOSE cur; END; Курсоры в SQL Server: статические и динамические
SQL Server предлагает четыре основных типа курсоров, которые отличаются поведением при изменении данных.
STATIC cursor
Создаёт временную копию данных в tempdb. Не видит изменений, сделанных после открытия. Самый безопасный, но и самый ресурсоёмкий.
DECLARE cur CURSOR STATIC FOR SELECT * FROM orders; DYNAMIC cursor

Отражает все изменения (вставки, обновления, удаления) в реальном времени. Чувствителен к блокировкам.
DECLARE cur CURSOR DYNAMIC FOR SELECT * FROM orders; FAST_FORWARD cursor
Оптимизирован для чтения только вперёд. Самый быстрый, но не поддерживает прокрутку назад.
DECLARE cur CURSOR FAST_FORWARD FOR SELECT * FROM orders; Курсоры в языках программирования: Python, Java, C#
В языках программирования курсоры — это, как правило, обёртки над курсорами БД. Они реализуют тот же принцип: последовательный доступ к результатам запроса с минимизацией потребления памяти.
- Python — через библиотеки: psycopg2 (PostgreSQL), sqlite3, mysql-connector-python.
- Java — через JDBC: Statement.executeQuery() возвращает ResultSet, который работает как курсор.
- C# — через SqlDataReader в ADO.NET.
Курсоры в Python: пример с psycopg2
Библиотека psycopg2 — стандарт для работы с PostgreSQL из Python. Курсор здесь — объект, который получается из соединения.
Получение курсора
import psycopg2 conn = psycopg2.connect("dbname=test user=postgres") cur = conn.cursor() Выполнение запроса

cur.execute("SELECT id, name FROM users WHERE active = %s", (True,)) Итерация по результатам
for row in cur: print(row[0], row[1]) Использование контекстного менеджера with гарантирует закрытие курсора и соединения.
with psycopg2.connect(...) as conn: with conn.cursor() as cur: cur.execute(...) Курсоры в Java: JDBC Statement и ResultSet
В JDBC ResultSet — это курсор, который по умолчанию движется только вперёд. Можно создать прокручиваемый ResultSet, но это дорого.
Создание Statement
Statement stmt = conn.createStatement(); Получение ResultSet
ResultSet rs = stmt.executeQuery("SELECT id, name FROM users"); Навигация по ResultSet

while (rs.next()) { int id = rs.getInt("id"); String name = rs.getString("name"); } Для прокрутки назад нужно явно указать тип: conn.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY).
Курсоры в C#: SqlDataReader
SqlDataReader — потоковый курсор, который читает данные по одной строке, не загружая всё в память.
Создание SqlDataReader
using (SqlConnection conn = new SqlConnection(connString)) { conn.Open(); SqlCommand cmd = new SqlCommand("SELECT id, name FROM users", conn); SqlDataReader reader = cmd.ExecuteReader(); while (reader.Read()) { int id = reader.GetInt32(0); string name = reader.GetString(1); } reader.Close(); } Закрытие reader
Обязательно закрывайте reader (или используйте using). Иначе соединение останется занятым.
Производительность и альтернативы курсорам
Главный миф о курсорах: они всегда медленные. На самом деле, медленным может быть неправильное использование. Но в 90% случаев set-based операции работают быстрее. Почему? Потому что СУБД оптимизирована для работы с наборами, а не с одной строкой за раз.
| Операция | Курсор (построчно) | Set-based |
|---|---|---|
| Обновление 1 млн строк | ~10-30 секунд (в зависимости от СУБД) | ~0.5-2 секунды |
| Потребление памяти | Минимальное (одна строка) | Может быть высоким (весь набор) |
| Гибкость | Максимальная (можно прервать, изменить логику) | Ограниченная (всё в одном запросе) |
Важно: всегда оценивайте необходимость курсора. В большинстве случаев set-based операции работают быстрее. Курсор оправдан, когда логика обработки строки зависит от предыдущих строк или требует вызова внешних сервисов.
Когда использовать курсоры, а когда — нет

Сценарии для курсоров
- Сложная бизнес-логика, которую нельзя выразить одним SQL-запросом (например, расчёт накопительных итогов с условиями).
- Каскадные обновления связанных таблиц, где каждое обновление зависит от предыдущего.
- Динамические запросы, где структура данных меняется на лету.
Сценарии против курсоров
- Массовые обновления или вставки (лучше
UPDATE ... WHEREилиINSERT ... SELECT). - Агрегации и отчёты (оконные функции справятся быстрее).
- Любая операция, которую можно выразить одним
MERGEилиJOIN.
Set-based операции: лучший выбор
Set-based подход — это когда одна команда SQL обрабатывает множество строк. Вместо курсора:
-- Вместо курсора: UPDATE employees SET salary = salary * 1.1 WHERE department_id = 50; Преимущества: скорость, атомарность, меньше кода, легче тестировать.
BULK COLLECT и FORALL: массовая обработка
Если без курсора не обойтись, используйте BULK COLLECT (в Oracle) или аналоги в других СУБД. Это компромисс: вы загружаете строки пачками, а не по одной.
BULK COLLECT

SELECT employee_id BULK COLLECT INTO v_ids FROM employees WHERE department_id = 50; FORALL
FORALL i IN 1..v_ids.COUNT UPDATE employees SET salary = salary * 1.1 WHERE employee_id = v_ids(i); Производительность может быть в 10-100 раз выше, чем построчный FETCH.
Практические примеры использования курсоров
Теперь перейду к реальным сценариям, с которыми сталкивался сам.
Пример 1: Генерация отчёта с курсором
Задача: сформировать текстовый отчёт по заказам за день, где для каждого клиента нужно вывести список его заказов и общую сумму.
Код примера (Oracle PL/SQL)
DECLARE CURSOR cur_clients IS SELECT client_id, name FROM clients; v_client_id clients.client_id%TYPE; v_name clients.name%TYPE; v_total NUMBER; v_report CLOB; BEGIN OPEN cur_clients; LOOP FETCH cur_clients INTO v_client_id, v_name; EXIT WHEN cur_clients%NOTFOUND; SELECT SUM(total) INTO v_total FROM orders WHERE client_id = v_client_id; v_report := v_report || 'Клиент: ' || v_name || ', сумма: ' || v_total || CHR(10); END LOOP; CLOSE cur_clients; -- сохраняем отчёт DBMS_OUTPUT.PUT_LINE(v_report); END; Пояснение

Здесь курсор нужен, потому что для каждого клиента выполняется подзапрос к заказам. Set-based альтернатива — SELECT c.name, SUM(o.total) FROM clients c LEFT JOIN orders o ON c.client_id = o.client_id GROUP BY c.name — была бы быстрее, но если логика сложнее (например, форматирование строки), курсор оправдан.
Пример 2: Каскадное обновление данных
Задача: при изменении статуса заказа нужно обновить статусы всех связанных с ним платежей и доставок.
Код примера (T-SQL)
DECLARE @order_id INT, @new_status VARCHAR(20); DECLARE cur CURSOR FOR SELECT order_id, new_status FROM order_updates WHERE processed = 0; OPEN cur; FETCH NEXT FROM cur INTO @order_id, @new_status; WHILE @@FETCH_STATUS = 0 BEGIN UPDATE payments SET status = @new_status WHERE order_id = @order_id; UPDATE deliveries SET status = @new_status WHERE order_id = @order_id; UPDATE order_updates SET processed = 1 WHERE order_id = @order_id; FETCH NEXT FROM cur INTO @order_id, @new_status; END; CLOSE cur; DEALLOCATE cur; Пояснение
Курсор здесь — вынужденная мера, если логика обновления сложная и зависит от внешних условий. Но если можно сделать UPDATE payments SET status = (SELECT new_status FROM order_updates WHERE order_id = payments.order_id) — это будет быстрее.
Пример 3: Миграция данных с трансформацией
Задача: перенести данные из старой таблицы в новую с изменением формата (например, объединить поля).
Код примера (PostgreSQL)

DO $$ DECLARE cur CURSOR FOR SELECT id, first_name, last_name, phone FROM old_users; v_id INT; v_first_name VARCHAR; v_last_name VARCHAR; v_phone VARCHAR; BEGIN OPEN cur; LOOP FETCH cur INTO v_id, v_first_name, v_last_name, v_phone; EXIT WHEN NOT FOUND; INSERT INTO new_users (id, full_name, contact) VALUES (v_id, v_first_name || ' ' || v_last_name, v_phone); END LOOP; CLOSE cur; END; $$; Пояснение
Здесь курсор позволяет выполнить трансформацию на лету. Set-based вариант — INSERT INTO new_users SELECT id, first_name || ' ' || last_name, phone FROM old_users — был бы проще, но если трансформация включает сложную логику (например, проверку дубликатов), курсор даёт больше контроля.
Ошибки и лучшие практики при работе с курсорами
За годы работы я наступил на все эти грабли. Вот список типичных ошибок и как их избежать.
| Ошибка | Последствия | Решение |
|---|---|---|
| Незакрытый курсор | Утечка ресурсов, блокировки таблиц | Всегда использовать CLOSE (или using в C#/Python) |
| Отсутствие обработки исключений | Аварийное завершение, потеря данных | Оборачивать в BEGIN…EXCEPTION…END |
| Неправильный тип курсора | Лишние блокировки или неверные данные | Выбирать STATIC для отчётов, FAST_FORWARD для простого чтения |
| Курсор в триггере | Каскадные сбои, падение производительности | Избегать курсоров в триггерах — использовать set-based |
Частая ошибка: забыть закрыть курсор при возникновении исключения. Всегда используйте блоки обработки ошибок или конструкции с автоматическим закрытием.
Типичные ошибки
Незакрытый курсор
В PL/SQL, если курсор не закрыт, он занимает память и может блокировать другие сессии. В C# забытый SqlDataReader блокирует соединение.
Отсутствие обработки исключений

Если внутри цикла возникает ошибка, курсор остаётся открытым, а транзакция — незавершённой. Всегда используйте BEGIN ... EXCEPTION ... WHEN OTHERS THEN CLOSE cur; RAISE; END;.
Неправильный тип курсора
В SQL Server выбор DYNAMIC вместо STATIC может привести к блокировкам, если данные изменяются параллельно.
Рекомендации по оптимизации
FOR LOOP
Вместо явного OPEN/FETCH/CLOSE используйте FOR rec IN cur LOOP ... END LOOP; — это короче и автоматически закрывает курсор.
FOR rec IN (SELECT * FROM orders) LOOP -- обработка END LOOP; Ограничение строк
Если можно обработать только часть данных, используйте WHERE ROWNUM <= 1000 (Oracle) или LIMIT (PostgreSQL).
BULK COLLECT

Для массовой выборки используйте BULK COLLECT с ограничением размера пачки (например, LIMIT 1000). Это снижает нагрузку на память.
«Курсор — это как скальпель: в руках хирурга — спасение, в руках дилетанта — опасность. Используйте его только тогда, когда другие инструменты бессильны.»
Заключение: резюме и дальнейшее изучение
Курсоры — мощный, но ресурсоёмкий инструмент. Они незаменимы для сложной построчной логики, но в 90% случаев set-based операции быстрее и надёжнее. Главные выводы:
- Всегда оценивайте, можно ли заменить курсор одним SQL-запросом.
- Если курсор неизбежен, используйте массовые операции (BULK COLLECT, FORALL).
- Не забывайте закрывать курсоры и обрабатывать исключения.
- Изучайте документацию вашей СУБД — возможности сильно различаются.
Для углублённого изучения рекомендую:
- Документация Oracle по курсорам
- PostgreSQL: курсоры в PL/pgSQL
- SQL Server: курсоры в T-SQL
- Книга «SQL for Smarties» Джо Селко — отличный источник по set-based мышлению.
Если вы хотите глубже разобраться в том, как современные инструменты автоматизации кода меняют подход к написанию программ, рекомендую прочитать статью «Как Cursor меняет подход к написанию программ». А для тех, кто интересуется ИИ-инструментами, будет полезна статья «Cursor: ИИ-инструмент для автоматизации кода в 2026».
Часто задаваемые вопросы
Что такое курсор в SQL простыми словами?
Это указатель на текущую строку в результате запроса. Позволяет обрабатывать строки по одной, как если бы вы читали книгу страница за страницей.
Когда использовать курсор, а когда — set-based операцию?
Курсор — когда логика обработки строки зависит от предыдущих строк или требует вызова внешних сервисов. Set-based — для массовых обновлений, агрегаций, фильтрации.
Почему курсоры считаются медленными?

Потому что они обрабатывают строки по одной, вызывая множество переключений контекста между СУБД и приложением. Set-based операции работают с целыми наборами данных за один проход.
Как избежать утечки ресурсов при работе с курсором?
Всегда закрывайте курсор после использования (CLOSE). В языках программирования используйте конструкции with (Python) или using (C#). В PL/SQL — блоки BEGIN…EXCEPTION…END.
Можно ли использовать курсор в триггере?
Технически — да, но крайне не рекомендуется. Это может привести к каскадным проблемам и падению производительности. Лучше использовать set-based операции.
Что такое REF CURSOR в Oracle?
Это переменная-курсор, которая может быть открыта для любого запроса, в том числе динамического. Используется для передачи курсоров между процедурами и функциями.