{"id":802,"date":"2026-10-27T05:04:36","date_gmt":"2026-10-27T05:04:36","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/oracle-to-postgresql-migration-pgrecon-the-residue-report-i-would-price-from\/"},"modified":"2026-10-27T05:04:36","modified_gmt":"2026-10-27T05:04:36","slug":"oracle-to-postgresql-migration-pgrecon-the-residue-report-i-would-price-from","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/oracle-to-postgresql-migration-pgrecon-the-residue-report-i-would-price-from\/","title":{"rendered":"Oracle to PostgreSQL Migration: The Residue Report I&#8217;d Price From"},"content":{"rendered":"<p>Short version for the impatient: before you pick an Oracle to PostgreSQL migration tool, ask it for the list of things it refuses to convert. That list is your quote. The converted DDL is the cheap part.<\/p>\n<p>I read the <a href=\"https:\/\/www.postgresql.org\/about\/news\/pgrecon-open-source-oracle-to-postgresql-migration-assessment-and-schema-conversion-3410\/\" rel=\"nofollow noopener\" target=\"_blank\">pgrecon announcement on the PostgreSQL news page<\/a> twice this week. The first read was for the feature list. The second was because the benchmark table made me suspicious, and I wanted to work out whether the suspicion was fair. I haven&rsquo;t run pgrecon against a real Oracle estate, so treat everything below as a reading of what the project claims plus my own habits from client migrations, not a test report.<\/p>\n<p>If you quote fixed-price database moves for clients, or you&rsquo;re the person who gets handed one, this is the part of the problem I&rsquo;d look at first.<\/p>\n<h2 id=\"why-the-schema-script-is-the-cheap-part\">Why the schema script is the cheap part<\/h2>\n<p>Every Oracle migration pitch starts with a demo where tables go in one side and come out the other. Tables are easy. A table is columns, types and constraints, and the mapping is mostly mechanical. The money disappears somewhere else: PL\/SQL packages that keep state between calls, triggers that write audit rows in their own transaction, jobs scheduled inside the database, and application code that quietly depends on how Oracle treats empty strings.<\/p>\n<p>That last one deserves a paragraph because it bites people who think they&rsquo;ve finished. Oracle treats an empty string as NULL. PostgreSQL does not. A column that has always been &ldquo;optional&rdquo; in practice, with the application sending an empty string, behaves one way on Oracle and another on PostgreSQL. Your <code>WHERE col IS NULL<\/code> filters, your <code>NOT NULL<\/code> constraints and your unique indexes all change meaning without a single syntax error. No DDL converter can fix that. Only an assessment that goes looking for it can.<\/p>\n<p>So the useful question for any tool is not &ldquo;how much did it convert?&rdquo; It&rsquo;s &ldquo;how clearly did it tell me what it left behind?&rdquo;<\/p>\n<h2 id=\"what-pgrecon-says-it-does\">What pgrecon says it does<\/h2>\n<p>The project describes three stages. Your DBA runs one read-only SQL*Plus script and sends back the spool files. The tool loads them into a local SQLite inventory and never connects to either database. Then a rule engine of 80 rules runs over the inventory, and a converter emits PostgreSQL DDL for what it can prove equivalent.<\/p>\n<p>Two design choices stood out to me.<\/p>\n<p>The first is the offline dump. I like this more than I expected to. A lot of Oracle estates belong to companies where nobody will hand a contractor a connection string in week one. A short, auditable script that a DBA reads line by line and runs with a read-only account is a much easier yes. The <a href=\"https:\/\/muzzammil242.github.io\/pgrecon\" rel=\"nofollow noopener\" target=\"_blank\">project page<\/a> says the script is short enough to audit, and that&rsquo;s the right claim to make, because it&rsquo;s the thing a security reviewer will actually check.<\/p>\n<p>The second is the residue report. The page says everything the converter can&rsquo;t carry faithfully becomes a named line instead of emitted DDL. Their examples are the sort of thing I&rsquo;d want to see: a view using <code>JSON_TABLE<\/code> that needs a hand rewrite because PostgreSQL 17 added it with different clause syntax, a trigger that runs an autonomous transaction, a compound trigger, an external file locator column with no counterpart.<\/p>\n<p>Read those four lines again. Each one is a different kind of work. The view is an afternoon. The autonomous transaction is a design decision, because you&rsquo;re choosing between <code>dblink<\/code>, a queue, or changing what the audit table means. The compound trigger is a restructure. The file locator is a conversation with whoever owns the files. A flat list with reasons attached is how you turn &ldquo;it&rsquo;s about six months&rdquo; into a number you can defend.<\/p>\n<h2 id=\"what-the-conversions-actually-look-like\">What the conversions actually look like<\/h2>\n<p>Here&rsquo;s the flavour of what a converter has to get right. Take the classic Oracle top-N pattern:<\/p>\n<pre><code class=\"language-sql\">-- Oracle\nSELECT * FROM (\n  SELECT employee_id, last_name\n  FROM employees\n  ORDER BY salary DESC\n)\nWHERE ROWNUM &lt;= 10;\n<\/code><\/pre>\n<pre><code class=\"language-sql\">-- PostgreSQL\nSELECT employee_id, last_name\nFROM employees\nORDER BY salary DESC\nLIMIT 10;\n<\/code><\/pre>\n<p>The announcement says the converter now turns ROWNUM top-N bounds into <code>LIMIT<\/code>. Fine, but notice what the tool has to prove. If somebody wrote the lazy version, <code>WHERE ROWNUM &lt;= 10 ORDER BY salary DESC<\/code> with no subquery, Oracle applies the row limit before the sort and returns ten arbitrary rows. A mechanical conversion to <code>LIMIT<\/code> would silently start returning the real top ten. That&rsquo;s arguably the behaviour the author wanted, and it&rsquo;s still a change that someone should sign off on. I&rsquo;d want to know whether the tool flags that shape or just rewrites it.<\/p>\n<p>Hierarchies are the other one I always check, and the announcement says <code>CONNECT BY<\/code> becomes <code>WITH RECURSIVE<\/code>:<\/p>\n<pre><code class=\"language-sql\">-- Oracle\nSELECT employee_id, manager_id, LEVEL\nFROM employees\nSTART WITH manager_id IS NULL\nCONNECT BY PRIOR employee_id = manager_id;\n<\/code><\/pre>\n<pre><code class=\"language-sql\">-- PostgreSQL\nWITH RECURSIVE tree AS (\n  SELECT employee_id, manager_id, 1 AS level\n  FROM employees\n  WHERE manager_id IS NULL\n  UNION ALL\n  SELECT e.employee_id, e.manager_id, t.level + 1\n  FROM employees e\n  JOIN tree t ON e.manager_id = t.employee_id\n)\nSELECT employee_id, manager_id, level FROM tree;\n<\/code><\/pre>\n<p>That one is mechanical for simple cases. It gets uglier with <code>CONNECT_BY_ISCYCLE<\/code>, <code>SYS_CONNECT_BY_PATH<\/code> and <code>ORDER SIBLINGS BY<\/code>, none of which the announcement mentions. Absence from a press release isn&rsquo;t evidence of absence from the tool. I&rsquo;d test exactly those on a sample schema before believing any summary.<\/p>\n<p>There&rsquo;s also a quieter item in the announcement: both extraction tiers capture character versus byte length semantics, so <code>VARCHAR2(30 CHAR)<\/code> maps to <code>varchar(30)<\/code>. This matters because PostgreSQL&rsquo;s <code>varchar(n)<\/code> counts characters. A <code>VARCHAR2(30 BYTE)<\/code> column on a UTF-8 database holds fewer characters than the same number in PostgreSQL. Moving to the looser limit is usually harmless. Moving data the other direction later, if you keep Oracle as a fallback during cutover, is where it hurts.<\/p>\n<h2 id=\"reading-a-benchmark-the-vendor-ran\">Reading a benchmark the vendor ran<\/h2>\n<p>Now the part that made me suspicious. The project page has a table of apply errors against a live PostgreSQL 16 after converting nine schemas. pgrecon shows zero. The other tools show 40, 58, 77, 135 and 481, depending on the tool, and the page adds notes such as &ldquo;PL\/SQL out of scope by design&rdquo; and &ldquo;tables, indexes and sequences only&rdquo;.<\/p>\n<p>I&rsquo;m not saying the numbers are wrong. The page says the benchmark workflow lives in the repository and is re-run in CI, which is exactly how this should be published, and I&rsquo;d rather see a reproducible vendor benchmark than none at all. But I&rsquo;d hold three reservations.<\/p>\n<p>One, the author picked the schemas. Oracle&rsquo;s HR, OE and CO samples plus a handful of open-source PL\/SQL libraries are a sensible public set, and they&rsquo;re also a set the author could tune against for months. Your estate isn&rsquo;t in it.<\/p>\n<p>Two, &ldquo;apply errors&rdquo; counts statements PostgreSQL rejected. It doesn&rsquo;t count statements PostgreSQL accepted that now behave differently. A tool that declines hard cases gets a perfect score on this metric by design, and the page is open about that. Declining is the honest behaviour, and it still isn&rsquo;t the same thing as converting.<\/p>\n<p>Three, the comparison tools are measured at specific versions on specific dates, and some of the notes describe scope choices, not defects. The CYBERTEC and Dalibo notes say what they cover. Reading &ldquo;40 errors&rdquo; next to &ldquo;0 errors&rdquo; without those notes would be unfair to both.<\/p>\n<p>The version of this that I do trust is the project&rsquo;s own statement of what it declines. A tool that publishes its residue is easier to audit than one that publishes a score.<\/p>\n<h2 id=\"how-id-run-an-assessment-this-week\">How I&rsquo;d run an assessment this week<\/h2>\n<p>If I had an Oracle estate on my desk tomorrow, this is the order I&rsquo;d go in. It&rsquo;s the order that spends the least client money before the first real decision.<\/p>\n<p>First, get the DBA to run the extraction script on a non-production copy with a read-only account, and read the script yourself before they do. If the client&rsquo;s security team balks at that, you&rsquo;ve learned something about the schedule.<\/p>\n<p>Second, install the tool with <code>pip install pgrecon<\/code> and run the report before the converter. Sort findings by severity and read the evidence lines. Don&rsquo;t skim the summary. A line-level hit on package-level state or on database links is worth more than a hundred green rows.<\/p>\n<p>Third, take the residue report and price each line separately, in days, with a name next to it. If a line says &ldquo;redesign with a queue&rdquo;, that&rsquo;s a design meeting and a test plan, not an hour.<\/p>\n<p>Fourth, check the empty-string behaviour by hand. Grep the application code for comparisons against <code>''<\/code> and for string concatenation over nullable columns. Oracle&rsquo;s <code>||<\/code> treats NULL as an empty string, and PostgreSQL&rsquo;s returns NULL, so <code>first_name || ' ' || middle_name<\/code> quietly starts blanking whole names. A schema dump can&rsquo;t show you that.<\/p>\n<p>Fifth, apply the converted DDL to a scratch PostgreSQL instance yourself. Don&rsquo;t take the apply-error count from anyone&rsquo;s table, including mine. If you want the data-movement side of a database move, I wrote about a MySQL to PostgreSQL one in <a href=\"https:\/\/abrarqasim.com\/blog\/postgresql-migrator-1-0-mysql-to-postgresql-the-row-that-kept-my-write-freeze\" rel=\"noopener\">the row that kept my write freeze<\/a>.<\/p>\n<p>If you take on this kind of work, my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">portfolio<\/a> lists the database and backend projects I do for clients. For partitioned tables in particular, read the <a href=\"https:\/\/www.postgresql.org\/docs\/current\/ddl-partitioning.html\" rel=\"nofollow noopener\" target=\"_blank\">PostgreSQL partitioning chapter<\/a> before you accept any automatic mapping of Oracle interval partitions, because PostgreSQL&rsquo;s declarative partitioning needs partitions to exist up front.<\/p>\n<p>This week, do one thing: ask whichever tool you&rsquo;re considering to produce its refusal list for a schema you own, and put a day estimate beside every line. If it can&rsquo;t produce the list, you have your answer about the quote.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Oracle to PostgreSQL migration quotes live or die on what the converter refuses to convert. Here is how I would read pgrecon&#8217;s residue report and its benchmark.<\/p>\n","protected":false},"author":2,"featured_media":801,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Oracle to PostgreSQL migration quotes live or die on what the converter refuses to convert. Here is how I would read pgrecon's residue report and its benchmark.","rank_math_focus_keyword":"oracle to postgresql migration","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[237],"tags":[878,877,879,238],"class_list":["post-802","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-databases","tag-database-migration-2","tag-oracle","tag-pgrecon","tag-postgresql"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/802","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=802"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/802\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/801"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=802"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=802"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=802"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}