<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sergey Rudenko</title>
    <description>The latest articles on DEV Community by Sergey Rudenko (@sergey_rudenko_40963c1b7f).</description>
    <link>https://dev.to/sergey_rudenko_40963c1b7f</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1759094%2F52b61aca-c2a4-488c-97c4-9355ba92e105.png</url>
      <title>DEV Community: Sergey Rudenko</title>
      <link>https://dev.to/sergey_rudenko_40963c1b7f</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sergey_rudenko_40963c1b7f"/>
    <language>en</language>
    <item>
      <title>Moving Oracle to PostgreSQL with a Single `INSERT` — No Oracle Client, No Python</title>
      <dc:creator>Sergey Rudenko</dc:creator>
      <pubDate>Mon, 24 Aug 2026 16:27:16 +0000</pubDate>
      <link>https://dev.to/sergey_rudenko_40963c1b7f/moving-oracle-to-postgresql-with-a-single-insert-no-oracle-client-no-python-4dml</link>
      <guid>https://dev.to/sergey_rudenko_40963c1b7f/moving-oracle-to-postgresql-with-a-single-insert-no-oracle-client-no-python-4dml</guid>
      <description>&lt;p&gt;&lt;em&gt;How to turn an embedded OLAP engine into a compact, lightning-fast ETL/ELT pipeline with no CSVs and no intermediate storage — and, if needed, parallelize Oracle reads across sessions under one consistent snapshot.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When people talk about DuckDB, they usually think of analytics: local queries to Parquet files, fast aggregations, notebooks, and data science. But DuckDB has another highly practical capability: &lt;strong&gt;it can connect data sources and sinks directly within a single SQL plan.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By adding extensions for Oracle and PostgreSQL, complex data migration turns into one elegant query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SELECT * FROM app.orders'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't just shorthand for some external script. DuckDB actually executes the entire pipeline: it reads rows directly from Oracle, transforms them into its highly optimized vector chunks, and streams them directly into PostgreSQL. &lt;strong&gt;No intermediate DuckDB table, no CSV files, and no clunky Python loops are needed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In this architecture, DuckDB doesn't act as the final storage, but as a powerful, &lt;strong&gt;in-process ETL/ELT engine&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Are Building (The Architecture)
&lt;/h2&gt;

&lt;p&gt;The setup involves three key components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Oracle ──TNS/TTC──&amp;gt; oracle_scanner ──&amp;gt; DuckDB SQL pipeline ──&amp;gt; postgres extension ──&amp;gt; PostgreSQL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;&lt;a href="https://github.com/krokozyab/quack-oracle" rel="noopener noreferrer"&gt;&lt;code&gt;oracle_scanner&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; reads from Oracle directly via the native TNS/TTC protocol. &lt;strong&gt;The huge advantage here:&lt;/strong&gt; It requires &lt;em&gt;absolutely no&lt;/em&gt; Oracle Instant Client, OCI, JDBC, ODBC, Python, or any separate proxy processes. Installing it is one SQL statement from the DuckDB Community Extensions — there is nothing else to put on the machine.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;DuckDB&lt;/strong&gt; sits in the middle, executing projections, filtering, type casting, and other transformations at vector speed.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The official &lt;a href="https://duckdb.org/docs/current/core_extensions/postgres/overview" rel="noopener noreferrer"&gt;&lt;code&gt;postgres&lt;/code&gt;&lt;/a&gt;&lt;/strong&gt; extension connects PostgreSQL as a natively accessible catalog for reading and writing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DuckDB is the "engine in the middle." It understands the schema of both sides and constructs a single, highly optimized physical plan for the &lt;code&gt;INSERT ... SELECT&lt;/code&gt; operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connecting Both Databases
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;oracle_scanner&lt;/code&gt; is published in the DuckDB Community Extensions, so installing it is two statements — no build, no &lt;code&gt;-unsigned&lt;/code&gt; flag. Version 0.1.0 targets DuckDB v1.5.5, which is the shell version used throughout this article.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;INSTALL&lt;/span&gt; &lt;span class="n"&gt;oracle_scanner&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;community&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="n"&gt;oracle_scanner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole installation: the signed binary is downloaded for your platform and it links nothing from an Oracle client. The PostgreSQL extension can be installed from the official DuckDB repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;INSTALL&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;LOAD&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is highly recommended to store connection credentials securely in the &lt;strong&gt;DuckDB Secrets Manager&lt;/strong&gt;, rather than hardcoding passwords in connection strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="n"&gt;ora&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;oracle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;HOST&lt;/span&gt; &lt;span class="s1"&gt;'oracle.internal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PORT&lt;/span&gt; &lt;span class="mi"&gt;1521&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SERVICE_NAME&lt;/span&gt; &lt;span class="s1"&gt;'ORCLPDB1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="s1"&gt;'app_reader'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="n"&gt;pg_target&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;HOST&lt;/span&gt; &lt;span class="s1"&gt;'postgres.internal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PORT&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="s1"&gt;'warehouse'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;USER&lt;/span&gt; &lt;span class="s1"&gt;'etl_writer'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;ATTACH&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SECRET&lt;/span&gt; &lt;span class="n"&gt;pg_target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target table has to exist before the &lt;code&gt;INSERT&lt;/code&gt; — the extension will not invent it for you. You can create it without leaving the DuckDB shell, because the attached catalog is writable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;       &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;    &lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;     &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;         &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;source_system&lt;/span&gt;  &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(Note: this DDL runs inside PostgreSQL — &lt;code&gt;pg&lt;/code&gt; is a remote catalog, not a local DuckDB database, and &lt;code&gt;VARCHAR&lt;/code&gt; lands there as &lt;code&gt;text&lt;/code&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A Single &lt;code&gt;INSERT&lt;/code&gt; Instead of a Cumbersome ETL App
&lt;/h2&gt;

&lt;p&gt;Now you can simultaneously extract data, conform it to the target model, and load it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_system&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;ORDER_ID&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CUSTOMER_ID&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CREATED_AT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AMOUNT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="s1"&gt;'oracle'&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;source_system&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'SELECT order_id, customer_id, created_at, amount
       FROM app.orders
      WHERE created_at &amp;gt;= :watermark'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;'watermark'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01 00:00:00'&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key benefits of this approach:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Clear Extract vs. Transform boundaries:&lt;/strong&gt; The watermark filter runs &lt;em&gt;inside&lt;/em&gt; Oracle — it is part of the statement you send, so only matching rows ever cross the network. DuckDB then handles type casting and adds the new &lt;code&gt;source_system&lt;/code&gt; column. (This is deliberate hand-written pushdown, not the automatic filter pushdown the extension can do for &lt;code&gt;ATTACH&lt;/code&gt;ed Oracle tables; that one is opt-in via &lt;code&gt;SET oracle_filter_pushdown = true&lt;/code&gt;.)&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Zero-friction loading:&lt;/strong&gt; The output of the &lt;code&gt;SELECT&lt;/code&gt; instantly becomes the input for writing to PostgreSQL.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Values stay values:&lt;/strong&gt; The bind parameter &lt;code&gt;:watermark&lt;/code&gt; is sent as a typed bind, never concatenated into the SQL text — so a value can never turn into syntax. The statement text is still yours to keep static.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whether you call this ETL or ELT depends on where you draw the line. But practically, the &lt;strong&gt;key advantage&lt;/strong&gt; is that your entire data movement logic is defined declaratively in a single SQL query, eliminating the need to write, deploy, and maintain a separate data pumping service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just Use &lt;code&gt;oracle_fdw&lt;/code&gt; in PostgreSQL?
&lt;/h2&gt;

&lt;p&gt;A natural question arises: if the goal is to query Oracle from PostgreSQL, why not just use the standard Foreign Data Wrapper (&lt;code&gt;oracle_fdw&lt;/code&gt;) directly inside Postgres? &lt;/p&gt;

&lt;p&gt;Here is why the DuckDB approach is fundamentally different and often superior:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero Client Dependencies (No OCI Nightmare):&lt;/strong&gt; &lt;code&gt;oracle_fdw&lt;/code&gt; requires installing Oracle Instant Client and OCI libraries directly on the PostgreSQL server OS. This is often an administrative nightmare and sometimes strictly prohibited in managed database environments (like AWS RDS or GCP Cloud SQL). In contrast, DuckDB's &lt;code&gt;oracle_scanner&lt;/code&gt; speaks the native TNS/TTC wire protocol. It requires zero Oracle binaries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decoupled Workload:&lt;/strong&gt; With FDW, the heavy lifting of data extraction and type conversion happens &lt;em&gt;inside&lt;/em&gt; your primary PostgreSQL database, potentially impacting production performance. By placing DuckDB in the middle (e.g., in a separate container, CI/CD runner, or sidecar), you offload the entire ETL workload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vectorized Transformations:&lt;/strong&gt; DuckDB processes data in columnar vectors, so type conversion and expression evaluation happen a batch at a time before the result is written to PostgreSQL. &lt;code&gt;oracle_fdw&lt;/code&gt; prefetches from Oracle in batches too, but hands rows to the PostgreSQL executor one tuple at a time, and every transformation you add runs in that row-at-a-time engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native Range-Sharded Parallelism:&lt;/strong&gt; As we'll see next, DuckDB makes it trivial to split reads into perfectly consistent parallel shards, which is incredibly difficult to achieve purely with FDW.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why the Table Doesn't Need to Fit in Memory
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;oracle_query&lt;/code&gt; is implemented as a &lt;strong&gt;streaming table function&lt;/strong&gt;. It requests the next batch of rows from Oracle (sized to match a standard DuckDB vector), hands the chunk to the next operator, and only then reads further.&lt;/p&gt;

&lt;p&gt;As a result, a simple &lt;code&gt;INSERT ... SELECT&lt;/code&gt; pipeline &lt;strong&gt;does not materialize the entire source table in RAM&lt;/strong&gt;. Memory is only consumed by the active chunks and operator buffers, making this approach extremely resource-efficient.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Caveat:&lt;/em&gt; Streaming doesn't defy the laws of query physics. Global sorts, massive &lt;code&gt;GROUP BY&lt;/code&gt;s, window functions, or unoptimized joins might still require significant memory or spill to disk. If your goal is pure data transfer, avoid adding blocking operators unless necessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Built-in Backpressure:&lt;/strong&gt; If PostgreSQL ingest slows down, the pipeline won't frantically read ahead from Oracle and overwhelm your system. The consumer dictates the pace, ensuring a stable, controlled, and resilient data flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling Up: When One Oracle Session Isn't Enough
&lt;/h2&gt;

&lt;p&gt;A standard &lt;code&gt;oracle_query&lt;/code&gt; uses a single Oracle session and one read thread. While perfect for incremental loads, a massive full-load might hit network round-trip or single-session throughput bottlenecks.&lt;/p&gt;

&lt;p&gt;To drastically accelerate throughput, the source can be partitioned by numeric key ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;source_system&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;ORDER_ID&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CUSTOMER_ID&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CREATED_AT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AMOUNT&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;DECIMAL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="s1"&gt;'oracle'&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_scan_parallel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'APP.ORDERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'ORDER_ID'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;shards&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How &lt;code&gt;oracle_scan_parallel&lt;/code&gt; works its magic:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Grabs the current Oracle System Change Number (SCN).&lt;/li&gt;
&lt;li&gt; Describes the table and checks the key is &lt;code&gt;NUMBER&lt;/code&gt;, then reads the min and max key values — already &lt;code&gt;AS OF SCN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; Divides the range into shards (half-open, the last one closing on the maximum, so every key belongs to exactly one shard).&lt;/li&gt;
&lt;li&gt; Opens multiple parallel sessions.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Crucially:&lt;/strong&gt; Reads every range &lt;code&gt;AS OF SCN&lt;/code&gt; using that same, single SCN.&lt;/li&gt;
&lt;li&gt; Adds one extra shard for rows whose key is &lt;code&gt;NULL&lt;/code&gt; — but only if the table actually has any, so you never pay for an idle session.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The unified SCN is the &lt;strong&gt;critical component&lt;/strong&gt; here. If you merely opened eight random connections, each would see a slightly different point in time. A row could update mid-flight and appear twice or vanish entirely. Using a &lt;strong&gt;Flashback snapshot&lt;/strong&gt; guarantees that the parallel result is a perfectly consistent snapshot of a single logical table version.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Requirements:&lt;/em&gt; The key must be an Oracle &lt;code&gt;NUMBER&lt;/code&gt; with integer bounds — a non-numeric key is refused by name rather than approximated. You also need &lt;code&gt;EXECUTE&lt;/code&gt; privileges on &lt;code&gt;SYS.DBMS_FLASHBACK&lt;/code&gt; and &lt;code&gt;FLASHBACK&lt;/code&gt; on the table. The shard count defaults to DuckDB's thread count and is capped at 256; it is also clamped to the width of the key range, so a table with five distinct keys gets five shards no matter what you ask for. The table name may be schema-qualified (&lt;code&gt;'APP.ORDERS'&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Note:&lt;/em&gt; This is range sharding, not automatic statistical balancing. A highly skewed key might cause some shards to finish early. Overall speed is still gated by your slowest link (source, transformations, network, or target write), so benchmark and adjust shards carefully to avoid overwhelming either database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Database Reconciliation — and What It Costs
&lt;/h2&gt;

&lt;p&gt;A migration is not finished until you have proven the two sides agree, and this is where querying both engines from one session pays off. Reconciliation becomes a &lt;code&gt;SELECT&lt;/code&gt; instead of a Python script that pages both databases into memory.&lt;/p&gt;

&lt;p&gt;But the naive version of that &lt;code&gt;SELECT&lt;/code&gt; is a full extract in disguise, and it is worth seeing why before you point it at a 200-million-row table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Looks declarative. Reads both tables end to end.&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'SELECT order_id, amount FROM app.orders'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;EXCEPT&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The join and the &lt;code&gt;EXCEPT&lt;/code&gt; happen &lt;strong&gt;in DuckDB&lt;/strong&gt;, so both sides have to arrive there first. &lt;code&gt;oracle_query&lt;/code&gt; sends exactly the text you wrote — no filter is added on the way. Checking &lt;code&gt;v$sql&lt;/code&gt; on the Oracle side after running a cross-database join confirms it: what arrives is&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nv"&gt;"ORDER_ID"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;"AMOUNT"&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"ORDERS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with no &lt;code&gt;WHERE&lt;/code&gt; clause at all. The same is true of an &lt;code&gt;ATTACH&lt;/code&gt;ed table: a join predicate against a PostgreSQL table is never pushed into Oracle, because Oracle cannot see the other table. And &lt;code&gt;SET oracle_filter_pushdown = true&lt;/code&gt; does not change that — it pushes simple &lt;em&gt;constant&lt;/em&gt; predicates (&lt;code&gt;AMOUNT &amp;gt; 100&lt;/code&gt;, &lt;code&gt;IS NULL&lt;/code&gt;, &lt;code&gt;IN (…)&lt;/code&gt;, an equality on text), and it is &lt;strong&gt;off by default&lt;/strong&gt;. Even &lt;code&gt;SELECT count(*)&lt;/code&gt; over an attached table reads one row per row over the wire; DuckDB does the counting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Compare summaries first, rows only where they disagree
&lt;/h3&gt;

&lt;p&gt;The fix is the same hand-written pushdown as in the load itself: make each database compute its own summary, and compare the summaries. Bucket by something that already exists in the data — a month, a day, a key range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Oracle computes this; one row per bucket crosses the network&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ora_buckets&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;TO_CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rows_cnt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;amount_sum&lt;/span&gt;
      &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
     &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;TO_CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;pg_buckets&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;to_char&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rows_cnt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;amount_sum&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;ora_buckets&lt;/span&gt; &lt;span class="k"&gt;EXCEPT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_buckets&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hundred buckets is a hundred rows over the wire, whatever the table weighs. Now descend, and only into what actually disagreed — with the bucket as a bind parameter, so the filter runs inside Oracle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
      &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
     &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;TO_DATE&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;  &lt;span class="n"&gt;ADD_MONTHS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TO_DATE&lt;/span&gt;&lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'YYYY-MM'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s1"&gt;'bucket'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'2026-07'&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;EXCEPT&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-07-01'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;DATE&lt;/span&gt; &lt;span class="s1"&gt;'2026-08-01'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Counts and sums catch missing and duplicated rows, but not a changed value that keeps the total intact. When you need content equality, hash the row — and note that this only works if both sides build &lt;strong&gt;the same text&lt;/strong&gt;: &lt;code&gt;LOWER(STANDARD_HASH(x, 'MD5'))&lt;/code&gt; in Oracle and &lt;code&gt;md5(x)&lt;/code&gt; in PostgreSQL are the same digest, which is easy to verify (&lt;code&gt;10|ACCOUNTING&lt;/code&gt; hashes to &lt;code&gt;cb357227…&lt;/code&gt; on both). Getting there means pinning the representation yourself: format numbers and dates explicitly, and give &lt;code&gt;NULL&lt;/code&gt; a marker, because &lt;code&gt;'a' || NULL&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt; in Oracle and one side will silently disagree with itself.&lt;/p&gt;

&lt;h3&gt;
  
  
  If you are going to scan Oracle more than once, scan it once
&lt;/h3&gt;

&lt;p&gt;Every &lt;code&gt;oracle_query&lt;/code&gt; is a fresh statement against Oracle, so three reconciliation queries over the same table are three full reads. Land it once and work locally — and for a large table, read it through several sessions at a single SCN:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ora_orders&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_scan_parallel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ora'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'APP.ORDERS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ORDER_ID'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shards&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;DuckDB spills to disk, so this is bounded by disk rather than RAM — the same property that lets the load itself run on a laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is This a Replacement for Airflow and CDC?
&lt;/h2&gt;

&lt;p&gt;No — and recognizing this makes the pattern much easier to apply correctly.&lt;/p&gt;

&lt;p&gt;DuckDB beautifully handles the &lt;strong&gt;data plane&lt;/strong&gt; of a single load: connect, read, transform, and write. However, a standalone SQL query does not handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  Scheduling and orchestration&lt;/li&gt;
&lt;li&gt;  Watermark state management&lt;/li&gt;
&lt;li&gt;  Retries and alerting&lt;/li&gt;
&lt;li&gt;  Deduplication on rerun&lt;/li&gt;
&lt;li&gt;  Schema evolution&lt;/li&gt;
&lt;li&gt;  Continuous redo-log CDC&lt;/li&gt;
&lt;li&gt;  Row count and checksum reconciliations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For one-off migrations, periodic batches, and backfills, this approach is phenomenal. For a production pipeline, wrap this query in a standard orchestrator and explicitly define your rerun semantics. (Pro tip: For large reloads, write to a staging PostgreSQL table first, validate, and then swap or merge).&lt;/p&gt;

&lt;h2&gt;
  
  
  Pre-Flight Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Types:&lt;/strong&gt; &lt;code&gt;NUMBER(p,0)&lt;/code&gt; up to 18 digits becomes &lt;code&gt;BIGINT&lt;/code&gt;, &lt;code&gt;NUMBER(p,s)&lt;/code&gt; becomes &lt;code&gt;DECIMAL&lt;/code&gt;, and unconstrained &lt;code&gt;NUMBER&lt;/code&gt; is returned as a string to preserve exact precision. Oracle &lt;code&gt;DATE&lt;/code&gt; maps to &lt;code&gt;TIMESTAMP&lt;/code&gt;. Unsupported columns are safely rejected during the bind phase.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;LOBs:&lt;/strong&gt; Reading &lt;code&gt;CLOB&lt;/code&gt;, &lt;code&gt;NCLOB&lt;/code&gt; and &lt;code&gt;BLOB&lt;/code&gt; works, but the row carries only a locator, so every value costs extra round-trips: 2,000 rows with a 2,000-character &lt;code&gt;CLOB&lt;/code&gt; measured &lt;strong&gt;1.22 s&lt;/strong&gt;, against &lt;strong&gt;0.004 s&lt;/strong&gt; for the same rows without that column. Don't &lt;code&gt;SELECT&lt;/code&gt; LOBs you don't need — and note that &lt;em&gt;writing&lt;/em&gt; a LOB back to Oracle is not supported, which matters only if you ever reverse the direction.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Consistency:&lt;/strong&gt; Parallel scan yields a consistent Oracle snapshot, but this is &lt;em&gt;not&lt;/em&gt; a distributed transaction. Design your cleanup and retry strategies on the PostgreSQL side.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Row Order:&lt;/strong&gt; Shards execute concurrently, so row insertion order is undefined (which is standard for relational tables).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Security First:&lt;/strong&gt; For Oracle Autonomous Database, the extension natively reads wallet ZIPs in-memory and connects via TCPS with mandatory certificate validation (TLS verification cannot be disabled).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Validation:&lt;/strong&gt; Always verify row counts and key-range aggregates post-migration — computed &lt;em&gt;inside&lt;/em&gt; each database and compared as summaries, not by pulling both tables into DuckDB and joining them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion: A Simpler Pipeline
&lt;/h2&gt;

&lt;p&gt;DuckDB is typically viewed as a lightweight analytical tool. But its embedded architecture combined with extensible data sources turns it into a &lt;strong&gt;versatile SQL data mover.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of writing a bloated application that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Loads an Oracle driver&lt;/li&gt;
&lt;li&gt;Manually loops and converts types&lt;/li&gt;
&lt;li&gt;Manages queues or writes fragile temporary files&lt;/li&gt;
&lt;li&gt;Loads a PostgreSQL driver&lt;/li&gt;
&lt;li&gt;Batches inserts manually&lt;/li&gt;
&lt;li&gt;Fights memory limits and backpressure&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;You get a purely declarative pipeline:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;postgres_target&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;transformed_columns&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;oracle_source&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And when you need more power, you scale out with consistent parallel range-shards without breaking a sweat.&lt;/p&gt;

&lt;p&gt;This isn't a bloated enterprise integration bus or complex CDC. It's something much sharper: &lt;strong&gt;a compact, lightning-fast batch ETL/ELT pipeline with zero intermediate layers&lt;/strong&gt;, running exactly where DuckDB runs.&lt;/p&gt;

&lt;p&gt;Sometimes, the ultimate data pipeline really is just a single &lt;code&gt;INSERT&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/krokozyab/quack-oracle" rel="noopener noreferrer"&gt;&lt;code&gt;oracle_scanner&lt;/code&gt;: Source Code &amp;amp; Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://duckdb.org/docs/current/core_extensions/postgres/overview.html" rel="noopener noreferrer"&gt;PostgreSQL extension — DuckDB documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://duckdb.org/docs/current/core_extensions/postgres/overview.html#writing-data-to-postgresql" rel="noopener noreferrer"&gt;Writing Data to PostgreSQL from DuckDB&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://duckdb.org/docs/current/sql/statements/insert.html" rel="noopener noreferrer"&gt;DuckDB &lt;code&gt;INSERT&lt;/code&gt; statement&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Every SQL statement in this article was run as written — Oracle Database 19c and PostgreSQL 16 in containers, DuckDB v1.5.5 with &lt;code&gt;oracle_scanner&lt;/code&gt; 0.1.0 installed from the Community Extensions and the official &lt;code&gt;postgres&lt;/code&gt; extension.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>duckdb</category>
      <category>oracledatabase</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
