Когда говорят о DuckDB, обычно вспоминают аналитику: локальные запросы к Parquet, быстрые агрегации, ноутбуки. Но у него есть свойство, к аналитике отношения не имеющее: он умеет соединять источник и приёмник данных внутри одного SQL-плана.
С расширениями для Oracle и PostgreSQL перенос данных сводится к одному запросу — без Instant Client, без OCI, без Python и без промежуточных файлов. А если одной сессии Oracle мало, чтение разбивается на параллельные шарды, читающие один согласованный снимок по единому SCN.
Сразу дисклеймер: я автор расширения oracle_scanner, о котором пойдёт речь. Оно свободное (Apache-2.0), и ниже я честно перечисляю, чего оно не умеет.
Что получается
INSERT INTO pg.public.orders SELECT * FROM oracle_query('ora', 'SELECT * FROM app.orders');
Это не сокращённая запись для внешнего скрипта. DuckDB исполняет весь конвейер сам: читает строки из Oracle, складывает их в свои векторные чанки и отдаёт в PostgreSQL. Промежуточная таблица DuckDB, CSV и цикл на Python не появляются.
Участников трое:
Oracle ──TNS/TTC──> oracle_scanner ──> DuckDB SQL pipeline ──> postgres extension ──> PostgreSQL
oracle_scannerчитает и пишет Oracle напрямую по её собственному сетевому протоколу TNS/TTC. Ему не нужны Instant Client, OCI, ODPI-C, ODBC, JDBC, Python или отдельный процесс-посредник: установка — один SQL-запрос из DuckDB Community Extensions, больше на машину ставить нечего.DuckDB посередине выполняет проекцию, фильтрацию, приведение типов и прочие трансформации.
Официальное расширение
postgresподключает PostgreSQL как каталог, доступный на чтение и запись.
DuckDB знает схему обеих сторон и строит один физический план для INSERT … SELECT.
Подключаем обе базы
oracle_scanner опубликован в DuckDB Community Extensions, так что установка — это два запроса: ни сборки из исходников, ни флага -unsigned. Версия 0.1.0 собрана под DuckDB v1.5.5 — в этой версии shell и выполнялись все запросы статьи.
INSTALL oracle_scanner FROM community; LOAD oracle_scanner;
Это вся установка: для вашей платформы скачивается подписанный бинарник, который не линкует ничего из клиента Oracle. PostgreSQL-расширение ставится из репозитория DuckDB:
INSTALL postgres; LOAD postgres;
Реквизиты подключения лучше держать в DuckDB Secrets Manager, а не в connection string:
CREATE SECRET ora ( TYPE oracle, HOST 'oracle.internal', PORT 1521, SERVICE_NAME 'ORCLPDB1', USER 'app_reader', PASSWORD '...' ); CREATE SECRET pg_target ( TYPE postgres, HOST 'postgres.internal', PORT 5432, DATABASE 'warehouse', USER 'etl_writer', PASSWORD '...' ); ATTACH '' AS pg ( TYPE postgres, SECRET pg_target, SCHEMA 'public' );
Пароль после этого не читается обратно: duckdb_secrets() показывает его как password=redacted.
Целевая таблица должна существовать до INSERT — расширение её не придумает. Но создать её можно, не выходя из того же shell, потому что подключённый каталог доступен на запись:
CREATE TABLE pg.public.orders ( order_id BIGINT, customer_id BIGINT, created_at TIMESTAMP, amount DECIMAL(18, 2), source_system VARCHAR );
Этот DDL выполняется в PostgreSQL: pg — удалённый каталог, а не локальная база DuckDB, и VARCHAR приезжает туда как text. Возможность создавать таблицы даёт официальное расширение postgres — его каталог доступен на запись целиком. С Oracle так нельзя: oracle_scanner не выпускает DDL вообще, и на CREATE TABLE в подключённой Oracle-схеме вы получите отказ с текстом «this extension does not issue DDL».
Один INSERT вместо ETL-приложения
Теперь можно за один запрос извлечь данные, привести их к целевой модели и загрузить:
INSERT INTO pg.public.orders ( order_id, customer_id, created_at, amount, source_system ) SELECT ORDER_ID::BIGINT, CUSTOMER_ID::BIGINT, CAST(CREATED_AT AS TIMESTAMP), CAST(AMOUNT AS DECIMAL(18, 2)), 'oracle' AS source_system FROM oracle_query( 'ora', 'SELECT order_id, customer_id, created_at, amount FROM app.orders WHERE created_at >= :watermark', {'watermark': TIMESTAMP '2026-08-01 00:00:00'} );
Здесь видна граница между extract и transform:
фильтр по watermark выполняется внутри Oracle — он часть отправленного текста запроса, поэтому по сети едут только подходящие строки;
приведения типов и добавление
source_systemделает DuckDB;результат
SELECTсразу становится входом для записи в PostgreSQL.
Отдельно стоит различать два разных «pushdown». В примере выше фильтр написан руками прямо в Oracle-SQL. У расширения есть и автоматический pushdown фильтров — но только для таблиц, подключённых через ATTACH, и он выключен по умолчанию (SET oracle_filter_pushdown = true). Выключен намеренно: DuckDB, отдав фильтр сканеру, убирает его из своего плана, поэтому транслятор обязан воспроизвести смысл предиката в Oracle точно — а всё, что он не может доказать, он отклоняет с ошибкой вместо приблизительного перевода. Например, WHERE name = 'SALES' уходит в Oracle, а WHERE name > 'A' — нет: порядок сравнения строк зависит от NLS_SORT и NLS_COMP, которые клиент не согласовывает.
Bind-параметр :watermark передаётся как типизированный bind и не склеивается с текстом запроса, так что значение не может превратиться в синтаксис. Текст самого запроса при этом по-прежнему пишете вы.
Называть это ETL или ELT — вопрос вкуса. Практически важнее, что логика переноса описана декларативно одним запросом и отдельный сервис-перекачиватель не появляется.
Почему не ora2pg
Первый вопрос, который возникает у всех, кто мигрирует Oracle в PostgreSQL: зачем это, если есть ora2pg.
Это разные задачи. ora2pg — инструмент разовой миграции: он читает словарь Oracle, генерирует DDL PostgreSQL, переписывает PL/SQL в PL/pgSQL, оценивает трудоёмкость проекта. Ему нужен Perl и DBD::Oracle, то есть тот самый Instant Client на машине, где он запущен.
Подход, описанный здесь, ничего не конвертирует и схему не переносит. Он про поток данных: инкрементальные догрузки по watermark, повторяемые батчи, сверку источника с приёмником — и всё это внутри SQL, без клиентских библиотек Oracle на машине.
Практично их сочетать: схему один раз переносит ora2pg, а регулярную перекачку данных дальше делает DuckDB.
Почему не oracle_fdw
Второй естественный вопрос: если нужно читать Oracle из PostgreSQL, почему не поставить Foreign Data Wrapper прямо в Postgres.
Клиентские зависимости.
oracle_fdwтребует Oracle Instant Client и библиотек OCI на самом сервере PostgreSQL. Это административная морока, а в управляемых СУБД (AWS RDS, GCP Cloud SQL) такой возможности нет вовсе.oracle_scannerне требует ни одного бинарника Oracle.Где выполняется работа. С FDW извлечение и конвертация типов происходят внутри вашей основной базы PostgreSQL, то есть за счёт её ресурсов. DuckDB посередине (в контейнере, на раннере CI, в sidecar) выносит эту нагрузку наружу.
Векторная обработка. DuckDB обрабатывает данные колоночными векторами, то есть преобразования типов и вычисление выражений идут пачками.
oracle_fdwтоже читает из Oracle батчами, но отдаёт строки исполнителю PostgreSQL по одной, и каждое добавленное преобразование выполняется в этом построчном движке.Параллельное чтение по диапазонам. О нём ниже — с FDW согласованный параллельный снимок собрать заметно сложнее.
Почему таблица не обязана помещаться в память
oracle_query — streaming table function. Он запрашивает у Oracle очередную порцию строк размером со стандартный вектор DuckDB, отдаёт чанк следующему оператору и только затем читает дальше.
Поэтому INSERT … SELECT не материализует исходную таблицу в RAM: память нужна на текущие чанки и буферы операторов.
Стриминг не отменяет физику запроса. Глобальная сортировка, большой GROUP BY, оконная функция или неудачный join по-прежнему потребуют памяти или spill на диск. Если задача — чистый перенос, не добавляйте блокирующие операторы без нужды.
Побочный полезный эффект — backpressure. Если PostgreSQL принимает медленнее, конвейер не вычитывает Oracle вперёд: темп задаёт потребитель.
Когда одной Oracle-сессии мало
Обычный oracle_query — это одна сессия и один поток чтения. Для инкрементальных загрузок обычно достаточно, для большого full-load узким местом становятся round-trip и пропускная способность одной сессии.
Тогда источник делится на диапазоны числового ключа:
INSERT INTO pg.public.orders ( order_id, customer_id, created_at, amount, source_system ) SELECT ORDER_ID::BIGINT, CUSTOMER_ID::BIGINT, CAST(CREATED_AT AS TIMESTAMP), CAST(AMOUNT AS DECIMAL(18, 2)), 'oracle' FROM oracle_scan_parallel( 'ora', 'APP.ORDERS', 'ORDER_ID', shards := 8 );
Что происходит внутри:
берётся текущий Oracle SCN;
описывается таблица и проверяется, что ключ —
NUMBER; читаются минимум и максимум ключа, ужеAS OF SCN;диапазон делится на шарды — полуоткрытые, кроме последнего, который замыкается на максимуме, так что каждый ключ попадает ровно в один шард;
открывается несколько сессий;
каждый диапазон читается как
AS OF SCNтого же самого SCN;если в таблице есть строки с
NULLв ключе, для них добавляется отдельный шард — но только если такие строки действительно есть, иначе лишней сессии не будет.
Единый SCN здесь — не оптимизация, а условие корректности. Если просто открыть восемь соединений, каждое увидит свой момент времени: строка может обновиться на лету, попасть в результат дважды или исчезнуть. Flashback-снимок делает восемь параллельных чтений одной логической версией таблицы. Если база отказывается выдать SCN, скан не выполняется вовсе — вместо того чтобы тихо прочитать несогласованные данные.
Требования: ключ должен быть Oracle NUMBER с целочисленными границами (нечисловой ключ отклоняется с ошибкой, а не обрабатывается приблизительно), нужны EXECUTE на SYS.DBMS_FLASHBACK и FLASHBACK на таблицу. Число шардов по умолчанию равно числу потоков DuckDB, максимум — 256; кроме того, оно урезается шириной диапазона ключа, так что таблица с пятью различными значениями получит пять шардов, сколько ни проси. Имя таблицы можно писать вместе со схемой: 'APP.ORDERS'.
Важная оговорка: одинаковые по ширине диапазоны не означают одинакового числа строк. На разрежённом или перекошенном ключе часть шардов закончит раньше. Это range sharding, а не балансировка по статистике. И шардируется только чтение Oracle: скорость всего INSERT всё равно ограничена самым медленным звеном — источником, трансформациями, сетью или записью в PostgreSQL. Восемь шардов не обещают восьмикратного ускорения, это параметр для измерений и с оглядкой на допустимую нагрузку на обе базы.
Сверка между разными СУБД — и чего она стоит
Миграция не закончена, пока не доказано, что стороны сходятся, и здесь мультиисточниковость DuckDB действительно экономит силы: сверка становится обычным SELECT, а не скриптом, который вытягивает обе базы в память.
Но наивная версия такого SELECT — это полная выгрузка под видом сверки, и лучше увидеть, почему, до того как навести её на таблицу в 200 миллионов строк:
-- выглядит декларативно, читает обе таблицы целиком SELECT order_id, amount FROM oracle_query('ora', 'SELECT order_id, amount FROM app.orders') EXCEPT SELECT order_id, amount FROM pg.public.orders;
JOIN и EXCEPT выполняет DuckDB, значит обе стороны сначала должны до него доехать. oracle_query отправляет ровно тот текст, который вы написали, — по дороге туда не добавляется ни один фильтр. v$sql на стороне Oracle после такого запроса показывает именно это:
SELECT "ORDER_ID", "AMOUNT" FROM "ORDERS"
без всякого WHERE. С таблицей, подключённой через ATTACH, то же самое: предикат соединения с таблицей PostgreSQL в Oracle не уедет никогда — Oracle не видит вторую таблицу. И SET oracle_filter_pushdown = true этого не меняет: он проталкивает простые предикаты с константами (AMOUNT > 100, IS NULL, IN (…), равенство по тексту), и по умолчанию он выключен. Даже SELECT count(*) по подключённой таблице тянет по строке на строку — считает всё равно DuckDB.
Сначала сводки, строки — только там, где разошлось
Лечится тем же ручным pushdown, что и сама загрузка: пусть каждая база посчитает свою сводку, а сравниваются уже сводки. Бакет берём из того, что в данных и так есть, — месяц, день, диапазон ключа:
-- считает Oracle; по сети едет одна строка на бакет CREATE OR REPLACE TABLE ora_buckets AS SELECT * FROM oracle_query('ora', $$ SELECT TO_CHAR(created_at, 'YYYY-MM') AS bucket, COUNT(*) AS rows_cnt, SUM(amount) AS amount_sum FROM app.orders GROUP BY TO_CHAR(created_at, 'YYYY-MM') $$); CREATE OR REPLACE TABLE pg_buckets AS SELECT to_char(created_at, 'YYYY-MM') AS bucket, count(*) AS rows_cnt, sum(amount) AS amount_sum FROM pg.public.orders GROUP BY 1; SELECT * FROM ora_buckets EXCEPT SELECT * FROM pg_buckets;
Сто бакетов — сто строк по сети, сколько бы таблица ни весила. Дальше спускаемся, и только в тот бакет, который действительно разошёлся, передавая границу бинд-параметром, чтобы фильтр отработал внутри Oracle:
SELECT order_id, amount FROM oracle_query('ora', $$ SELECT order_id, amount FROM app.orders WHERE created_at >= TO_DATE(:bucket, 'YYYY-MM') AND created_at < ADD_MONTHS(TO_DATE(:bucket, 'YYYY-MM'), 1) $$, {'bucket': '2026-07'}) EXCEPT SELECT order_id, amount FROM pg.public.orders WHERE created_at >= DATE '2026-07-01' AND created_at < DATE '2026-08-01';
Количества и суммы ловят потерянные и задвоенные строки, но не изменившееся значение, которое не сдвинуло итог. Когда нужна поразрядная сверка содержимого, считайте хеш строки — но работает это, только если обе стороны собирают одинаковый текст: LOWER(STANDARD_HASH(x, 'MD5')) в Oracle и md5(x) в PostgreSQL дают один и тот же дайджест (10|ACCOUNTING на обеих сторонах — cb357227…). Цена — фиксировать представление руками: явно форматировать числа и даты и подставлять маркер вместо NULL, потому что 'a' || NULL в Oracle равно NULL, и одна сторона молча разойдётся сама с собой.
Если Oracle придётся читать не один раз — прочитайте его один раз
Каждый oracle_query — это отдельный запрос к Oracle, поэтому три сверочных запроса по одной таблице означают три полных чтения. Приземлите её один раз и работайте локально, а большую таблицу читайте несколькими сессиями на одном SCN:
CREATE TABLE ora_orders AS SELECT * FROM oracle_scan_parallel('ora', 'APP.ORDERS', 'ORDER_ID', shards := 4);
DuckDB выгружает лишнее на диск, так что ограничение здесь дисковое, а не по памяти, — то же свойство, благодаря которому и сама загрузка работает на ноутбуке.
Это замена Airflow и CDC?
Нет, и понимание этого помогает применять приём по назначению.
DuckDB закрывает data plane одной загрузки: подключиться, прочитать, преобразовать, записать. Сам по себе SQL-запрос не решает:
расписание и оркестрацию;
хранение watermark;
retry и уведомления;
дедупликацию при повторном запуске;
schema evolution;
непрерывный CDC из redo log;
регулярную сверку строк и контрольных сумм.
Для разового переноса, периодического batch и backfill этого достаточно. Для production-пайплайна запрос стоит завернуть в привычный оркестратор и явно определить семантику повторного запуска. Для больших перезагрузок безопаснее писать в staging-таблицу PostgreSQL, проверять результат и только потом переключать или сливать данные внутри PostgreSQL: «один INSERT» упрощает транспорт, но не делает повторный запуск идемпотентным.
Что стоит знать до запуска
Типы. NUMBER(p,0) до 18 цифр читается как BIGINT, NUMBER(p,s) — как DECIMAL, а NUMBER без ограничений возвращается строкой, чтобы не потерять точность. Oracle DATE содержит время, поэтому отображается в TIMESTAMP, а не в DATE. TIMESTAMP WITH TIME ZONE приходит строкой — TIMESTAMPTZ в DuckDB потерял бы смещение. Неподдерживаемая колонка отклоняется на этапе bind с указанием имени и причины, а не декодируется приблизительно.
LOB. Чтение CLOB, NCLOB и BLOB работает, но в строке лежит только локатор, и каждое значение стоит дополнительных round-trip: 2000 строк с CLOB по 2000 символов — 1,22 с против 0,004 с на тех же строках без этой колонки. Не тащите LOB в SELECT без необходимости. Запись LOB обратно в Oracle не поддерживается — это важно, только если вы развернёте направление переноса.
Согласованность. Параллельный скан даёт согласованный снимок Oracle, но это не распределённая транзакция между двумя СУБД. Стратегию очистки после частичной загрузки нужно продумать на стороне PostgreSQL.
Порядок строк. Шарды выполняются конкурентно, порядок вставки не определён. Для реляционной таблицы это нормально, но полагаться на него нельзя.
Безопасность. Не храните боевые пароли в SQL-файлах. Для Oracle Autonomous Database расширение читает wallet ZIP в памяти (ничего не распаковывая на диск) и подключается по TCPS с обязательной проверкой сертификата и имени хоста; отключить проверку TLS нельзя — такого переключателя нет.
Проверка. Как минимум сравните число строк и агрегаты по диапазонам ключа — посчитанные внутри каждой базы и сопоставленные как сводки, а не вытянутые в DuckDB целиком ради JOIN. Для критичных миграций — хэши по детерминированному представлению колонок, отдельно по каждому батчу.
А это вообще законно
Вопрос закономерный, поэтому отвечу заранее.
Клиентская реализация того же протокола опубликована самой Oracle: драйвер python-oracledb в режиме Thin говорит по TNS/TTC и лежит в открытом доступе под Apache-2.0 / UPL. Кода и байтов Oracle в проекте нет, дизассемблирования не было — знание протокола получено из открытых источников и анализа собственного сетевого трафика между клиентом и сервером, которыми я управляю.
Всё это задокументировано: в репозитории есть PROVENANCE.md, где построчно указано, что откуда заимствовано и на каких условиях, с SHA-256 каждого сохранённого фрагмента трафика, а CI на каждой сборке проверяет собранный бинарник на отсутствие любых импортов из клиентских библиотек Oracle. Проект не аффилирован с Oracle, что отдельно написано в NOTICE.
Похожие независимые реализации живут давно: go-ora (MIT) — на GitHub с 2019 года.
Итог
DuckDB привычно воспринимают как маленькую аналитическую базу. Но embedded-архитектура плюс расширяемые источники делают из него ещё и SQL data mover.
Вместо приложения, которое открывает драйвер Oracle, вручную конвертирует типы, держит очередь или временный файл, открывает драйвер PostgreSQL, собирает batch insert и следит за памятью, получается декларативный конвейер:
INSERT INTO postgres_target SELECT transformed_columns FROM oracle_source;
А когда одной сессии мало, чтение разбивается на параллельные range-шарды без потери согласованности снимка.
Это не платформа интеграции и не CDC. Это инструмент поуже и потому предсказуемее: компактный batch ETL/ELT без промежуточного слоя, работающий там же, где работает DuckDB.
Ссылки
ora2pg — если задача в переносе схемы, а не потока данных
Все запросы из статьи выполнялись в том виде, в каком они приведены: Oracle Database 19c и PostgreSQL 16 в контейнерах, DuckDB v1.5.5 с oracle_scanner 0.1.0 из Community Extensions и официальным расширением postgres.

