Databases & SQL · پایگاه‌داده و SQL متوسطIntermediate ~84 دقیقه مطالعه~73 min read

SQL دو-گویشی: Oracle و PostgreSQLDual-Dialect SQL: Oracle & PostgreSQL

یک مرجع عمیق و دوگویشی که تفاوت‌های عملی PostgreSQL (16/17) و Oracle (19c/23ai) را — از صفحه‌بندی، upsert و sequence تا تله‌ی رشته‌ی خالی، تاریخ، JSON، ایزولاسیون تراکنش، plan و کدهای خطا — با تب‌های دوگویشی، کد واقعی Java/Spring و قضاوت سنیور آموزش می‌دهد.A deep dual-dialect reference that teaches the practical differences between PostgreSQL (16/17) and Oracle (19c/23ai) — pagination, upsert, sequences, the empty-string trap, dates, JSON, transaction isolation, plans and error codes — through dialect tabs, real Java/Spring code and senior-level judgment.

پیش‌نیاز:Prerequisites: تسلط بر SQL: Join، Window، CTE و ایندکسSQL Mastery: Joins, Windows, CTEs & Indexes


تصور کن دو نفر داری که هر دو «انگلیسی» حرف می‌زنند: یکی بریتانیایی و یکی آمریکایی. نود درصد کلمات یکی است، جمله‌ها را می‌فهمی، ولی گاهی یکی می‌گوید lift و دیگری elevator؛ یکی می‌نویسد colour و دیگری color. اگر متن رسمی می‌نویسی و این تفاوت‌ها را نشناسی، یا سوتی می‌دهی یا متن‌ات فقط برای یک مخاطب کار می‌کند.

Oracle و PostgreSQL دقیقاً همین‌اند: دو گویش از یک زبان به نام SQL. هر دو استاندارد ANSI/ISO SQL را دنبال می‌کنند، پس SELECT، JOIN، GROUP BY و تراکنش‌ها تقریباً یکسان‌اند. ولی هر کدام ده‌ها «واژه‌ی محلی» دارند که اگر ندانی، کدت روی یک دیتابیس کار می‌کند و روی دیگری با خطای مرموز می‌ترکد. این فصل نقشه‌ی کامل آن تفاوت‌هاست — با منطقِ پشتِ هر تصمیم، کدِ واقعی Java/Spring، و این‌که کجا در پروداکشن گازت می‌گیرد.

هر قطعه‌کد این فصل دو تب دارد: PostgreSQL و Oracle. روی تب دیتابیس خودت کلیک کن، ولی عادت کن هر دو را نگاه کنی — همان‌جاست که تفاوت گویش را با چشم می‌بینی، نه با حفظ‌کردن.

نقشه‌ی راه این فصل
  • مدل ذهنی: استاندارد SQL در برابر افزونه‌های اختصاصی، و چرا «قابل‌حمل نوشتن» یک تصمیم معماری است.
  • پایه‌ها: DUAL، quoting و طول شناسه، نگاشت نوع داده.
  • تله‌های خاموش: رشته‌ی خالیِ Oracle، DATEِ زمان‌دار، تقسیمِ صحیحِ PostgreSQL.
  • NULL، رشته، تاریخ: NVL/DECODE در برابر COALESCE/CASE، regex، INTERVAL.
  • مجموعه و صفحه‌بندی: MINUS/EXCEPT، FETCH FIRST، ROWNUM، keyset.
  • کلید، upsert و RETURNING: IDENTITY، SEQUENCE، ON CONFLICT در برابر MERGE.
  • پیشرفته: window، LISTAGG/STRING_AGG، CONNECT BY در برابر recursive CTE، JSON، جدول موقت.
  • موتور: MVCC و undo، ایزولاسیون، قفل، DDL تراکنشی، ORA-01555 در برابر bloat.
  • عملیات: کدهای خطا و Spring، plan و hint، PL/SQL در برابر PL/pgSQL، بارگذاری انبوه، استراتژی قابلیت حمل.

مدل ذهنی: استاندارد در برابر گویش

SQL سه لایه دارد:

  1. هسته‌ی استاندارد (ANSI SQL): SELECT/INSERT/UPDATE/DELETE، JOIN، GROUP BY، توابع پنجره‌ای، CASE، COALESCE. این‌ها را با خیال راحت همه‌جا بنویس.
  2. قسمت‌های استانداردی که هر vendor کمی متفاوت پیاده کرده: صفحه‌بندی، تولید کلید، توابع تاریخ. استاندارد یک راه رسمی دارد ولی هر کدام «راه محلیِ» خودشان را هم دارند و پیش‌فرض‌ها فرق می‌کند.
  3. افزونه‌های کاملاً اختصاصی: DECODE، CONNECT BY و hintها مالِ Oracle؛ DISTINCT ON، FILTER و آرایه‌های بومی مالِ PostgreSQL.
جعبه‌ابزار مشترک، آچارهای متفاوت

هر دو دیتابیس یک جعبه‌ابزار مشترک دارند (استاندارد SQL)، ولی هر کارگاه چند آچار سفارشیِ خودش را هم ساخته. اگر فقط با آچارهای مشترک کار کنی، ماشینت را می‌شود در هر دو کارگاه تعمیر کرد. لحظه‌ای که آچار سفارشی برمی‌داری، کارت سریع‌تر می‌شود ولی خودت را به آن کارگاه گره می‌زنی. سنیور بودن یعنی بدانی کِی سرعت می‌ارزد و کِی آزادیِ حمل.

قضاوت سنیور: تک‌دیتابیس یا چند‌دیتابیس؟

اگر تیمت تا ابد روی Oracle می‌ماند، از افزونه‌های Oracle استفاده کن — پول داده‌ای، از تواناییِ ابزار استفاده کن. ولی اگر محصولت را به مشتری‌هایی می‌فروشی که یکی Oracle دارد و یکی PostgreSQL، یا احتمال مهاجرت هست (فرار از هزینه‌ی لایسنس، موج بزرگ چند سال اخیر)، از روز اول با یک لایه‌ی انتزاع (Hibernate/jOOQ) و SQLِ قابل‌حمل بنویس. بازنویسیِ صدها کوئری اختصاصی بعداً، پروژه‌ی شش‌ماهه‌ی دردناک می‌سازد.

نمودار: جایگاه لایه‌ی دیالکت در معماری اپ. | Where the dialect layer sits in an app.

flowchart LR
  App[Spring Service] --> Repo[Spring Data / JPA]
  Repo --> HB[Hibernate Dialect]
  HB -->|PostgreSQLDialect| PG[(PostgreSQL 16/17)]
  HB -->|OracleDialect| ORA[(Oracle 19c/23ai)]
  App -. native query .-> Repo
  Migr[Flyway / Liquibase] --> PG
  Migr --> ORA

نکته‌ی مهم که همین ابتدا باید جا بیفتد: Hibernate با انتخاب Dialect درست، بخش بزرگی از این تفاوت‌ها را خودکار حل می‌کند (صفحه‌بندی، تولید کلید، نگاشت نوع). دردسرها جایی شروع می‌شوند که native query یا migration script خام می‌نویسی — آن‌جا خودت مسئول گویش هستی.

پایه‌ها: DUAL و SELECT بدون FROM

در Oracle، SELECT همیشه به FROM نیاز داشت؛ برای محاسبه‌ی یک عبارت بدون جدول، جدول جادوییِ تک‌ردیفه‌ی DUAL وجود دارد. PostgreSQL هیچ‌وقت FROM را اجباری نکرده:

SELECT now();
SELECT 1 + 1;
خبر خوب Oracle 23ai

از 23ai به بعد FROM اجباری نیست و می‌توانی مثل PostgreSQL بنویسی SELECT SYSDATE; — ولی DUAL هنوز کار می‌کند و میلیون‌ها خط کدِ قدیمی رویش سوارند. تا وقتی روی 19c هستی در Oracle عادت کن FROM DUAL بگذاری و در PostgreSQL هرگز ننویس. قابل‌حمل‌ترین کار: این عبارات را اصلاً به دیتابیس نفرست و در Java حساب کن. همین DUAL جای دیگری هم قربانی می‌گیرد: کوئریِ تستِ سلامتِ connection pool در Oracle باید SELECT 1 FROM DUAL باشد و در PostgreSQL SELECT 1.

حساسیت به حروف و quoting — منشأ باگ‌های شبانه

وقتی یک شناسه (نام جدول/ستون) را بدون دابل‌کوت می‌نویسی، Oracle آن را به حروف بزرگ تا می‌کند (EmployeesEMPLOYEES) و PostgreSQL به حروف کوچک (Employeesemployees). هر دو «case-insensitive» به‌نظر می‌رسند ولی در جهت‌های مخالف. فاجعه وقتی است که کسی جدول را با دابل‌کوت بسازد:

CREATE TABLE "Employees" (id int);      -- نام دقیقاً Employees می‌شود

SELECT * FROM Employees;    -- خطا: relation "employees" does not exist
SELECT * FROM "Employees";  -- درست
تله‌ی quoting که تیم‌ها را زمین می‌زند

هرگز جدول‌ها و ستون‌ها را با دابل‌کوت و حروف مخلوط نساز. یک ORM بد یا یک اسکریپت GUI که همه‌چیز را کوت می‌کند کافی است تا از آن به بعد هر کوئری دستی مجبور شود دقیقاً همان "CamelCase" را با کوت بنویسد. در Oracle این یعنی جدولی که در Enterprise Manager می‌بینی ولی SELECT * FROM employees پیدایش نمی‌کند. قانون طلایی: نام‌ها snake_case و بدون کوت. آن‌وقت هر دو دیتابیس شاد می‌مانند و کد بین‌شان جابه‌جا می‌شود.

سقف طول شناسه: ۱۲۸ در برابر ۶۳

Oracle از 12.2 شناسه‌ها را تا ۱۲۸ بایت می‌پذیرد (پیش از آن فقط ۳۰ بایت) و PostgreSQL تا ۶۳ بایت (NAMEDATALEN-1). خطر در جهتِ Oracle→PostgreSQL است: نام بلند در PostgreSQL بی‌صدا بریده می‌شود و اگر دو نام در ۶۳ بایتِ اول یکسان باشند برخورد می‌کنی. Hibernate و Liquibase هم گاهی نام‌های خودکارِ خیلی بلند برای index و FK می‌سازند؛ روی PostgreSQL نام‌ها را صریح و کوتاه بده. و یادت باشد سقف PostgreSQL بایت است نه کاراکتر.

Hibernate و naming strategy

Hibernate با PhysicalNamingStrategy نام entityها را به snake_case تبدیل می‌کند. این استاندارد را روشن نگه دار تا نه Oracle و نه PostgreSQL مجبور به quoting نشوند. اگر مجبوری با اسکیمای موجودِ کوت‌شده کار کنی، در @Table(name = "\"Employees\"") کوت را صریح بگذار — ولی این استثناست نه قاعده.

نگاشت نوع داده‌ها

این جدول را در جیبت داشته باش؛ در هر مهاجرت و هر DDL بهش برمی‌گردی:

مفهوم Oracle PostgreSQL نکته
رشته‌ی طول‌متغیر VARCHAR2(n) VARCHAR(n) یا TEXT Oracle سقف ۴۰۰۰ بایت (یا ۳۲۷۶۷ با MAX_STRING_SIZE=EXTENDED) و واحد پیش‌فرضش بایت است؛ VARCHAR2(100 CHAR) بنویس. PostgreSQL همیشه کاراکتر و تا ۱GB.
رشته‌ی بزرگ CLOB TEXT TEXT بدون سربار خاص و ایندکس‌پذیر است؛ CLOB نه.
عدد دقیق NUMBER(p,s) NUMERIC(p,s) NUMBER بدون آرگومان = دقت بالا.
عدد صحیح NUMBER(10) INTEGER/BIGINT Oracle نوع صحیحِ بومی ندارد.
اعشاری ماشینی BINARY_DOUBLE DOUBLE PRECISION برای پول هرگز float نه.
تاریخ فقط DATE (شامل زمان!) DATE (فقط تاریخ) تله‌ی بزرگ پایین‌تر.
تاریخ+زمان با منطقه TIMESTAMP WITH TIME ZONE TIMESTAMPTZ برای رویدادهای واقعی این را استفاده کن.
بازه INTERVAL DAY TO SECOND INTERVAL PostgreSQL یک نوعِ منعطف، Oracle دو نوع مجزا.
بولین BOOLEAN (فقط 23ai+) BOOLEAN پیش از 23ai در SQL نبود.
باینری BLOB/RAW BYTEA
JSON JSON (21c+) یا CLOB با IS JSON JSONB/JSON JSONB بهترین است.
شناسه‌ی یکتا RAW(16) + SYS_GUID() uuid + gen_random_uuid() PostgreSQL نوع بومی دارد.
آدرس فیزیکی ردیف ROWID ctid هیچ‌کدام را در اپ ذخیره نکن.
کلید خودافزا GENERATED … AS IDENTITY GENERATED … AS IDENTITY هر دو استاندارد را دارند.

همان جدول، در دو گویش:

CREATE TABLE customer (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email        varchar(320)  NOT NULL UNIQUE,
  full_name    text          NOT NULL,
  credit_limit numeric(12,2) NOT NULL DEFAULT 0,
  is_active    boolean       NOT NULL DEFAULT true,
  attributes   jsonb,
  created_at   timestamptz   NOT NULL DEFAULT now()
);
تله‌ی DATE در Oracle

در Oracle نوع DATE همیشه شامل ساعت، دقیقه و ثانیه است — نه فقط تاریخ. پس WHERE order_date = DATE '2026-07-23' فقط ردیف‌های دقیقاً نیمه‌شب را می‌آورد و بقیه گم می‌شوند. راه درست: >= DATE '2026-07-23' AND < DATE '2026-07-24'، یا TRUNC(order_date) = ... که ایندکس را می‌کُشد مگر function-based index بسازی. در PostgreSQL DATE واقعاً فقط تاریخ است و برای زمان از timestamptz استفاده می‌کنی. این ناهمخوانی یکی از پرتکرارترین باگ‌های مهاجرت است.

برای زمان همیشه timestamptz (و UTC)

در PostgreSQL timestamptz را بر timestamp بی‌منطقه ترجیح بده و همه‌چیز را UTC ذخیره کن؛ منطقه‌ی زمانی را در لایه‌ی نمایش اعمال کن. معادل Oracle، TIMESTAMP WITH TIME ZONE است. در Java هم Instant/OffsetDateTime نگه‌دار نه LocalDateTime. این یک تصمیم، بحثِ «چرا گزارش ساعت ۳ بامداد یک روز جابه‌جاست» را برای همیشه می‌بندد.

تله‌ی کم‌گفته‌شده‌ی دوم، تقسیمِ اعداد است:

SELECT 1 / 2;              -- 0  (تقسیم صحیح!)
SELECT 1.0 / 2;            -- 0.5
SELECT count(*)::numeric / total FROM stat;   -- درصدِ درست
`1/2` در PostgreSQL صفر است

در Oracle همه‌ی اعداد NUMBERاند، پس 1/2 می‌شود 0.5. در PostgreSQL اگر هر دو عملوند integer باشند تقسیم صحیح انجام می‌شود و 1/2 صفر است — بدون هیچ خطایی. کوئری‌ای که در Oracle درصدِ درست می‌داد، در PostgreSQL بی‌سروصدا صفر برمی‌گرداند: بدترین نوع باگ. درمان: یکی از عملوندها را cast کن (count(*)::numeric / total). تقسیم بر صفر در هر دو خطاست (ORA-01476 در برابر SQLSTATE 22012).

تله‌ی بزرگ: رشته‌ی خالی در Oracle همان NULL است

اگر فقط یک چیز از این فصل یادت بماند، همین باشد:

SELECT '' IS NULL;        -- false
SELECT length('');        -- 0
SELECT '' = '';           -- true

Oracle رشته‌ی خالی '' را با NULL یکسان می‌داند. این رفتار تاریخی است و Oracle خودش گفته شاید روزی عوض شود، پس نه روی «'' برابر NULL می‌ماند» حساب کن و نه روی «روزی جدا می‌شود». در PostgreSQL و استاندارد SQL، رشته‌ی خالی یک مقدارِ معتبر و متفاوت از NULL است.

وقتی رشته‌ی خالی کد Java را می‌ترکاند

سناریوی واقعی: فرم یک middleName خالی می‌فرستد و اپ "" را در ستونِ NOT NULL می‌نویسد. روی PostgreSQL بی‌مشکل ذخیره می‌شود؛ همان کد روی Oracle با ORA-01400: cannot insert NULL می‌ترکد چون Oracle آن "" را NULL دیده. تیم ساعت‌ها دنبال «NULL از کجا آمد؟» می‌گردد در حالی‌که کد اصلاً NULL نفرستاده. درمان: در مرزِ ورودی تصمیم بگیر — یا خالی‌ها را به null نرمال کن و ستون را nullable کن، یا یک پیش‌فرضِ معنادار بگذار.

چرا در Oracle طول یک رشته‌ی خالی NULL است، و چه ریسکی می‌سازد؟

چون Oracle به‌طور تاریخی '' را معادل NULL نگه می‌دارد؛ پس LENGTH('') مقدار NULL می‌دهد نه 0، و '' IS NULL مقدار TRUE است. ریسک‌ها: (۱) درج "" در ستون NOT NULL با ORA-01400 رد می‌شود؛ (۲) شرطِ col = '' هرگز TRUE نمی‌شود چون مقایسه با NULL همیشه UNKNOWN است و باید col IS NULL نوشت؛ (۳) کدی که بین دو موتور جابه‌جا می‌شود رفتار متفاوت نشان می‌دهد — مثلاً UNIQUE روی ستونی که در Oracle چند ردیفِ «خالی» را می‌پذیرد (چون همه NULL‌اند) ولی در PostgreSQL نمی‌پذیرد. راهکار سنیور: ورودی را در لایه‌ی اپ نرمال کن و در کوئری‌ها به‌جای = '' از IS NULL/COALESCE استفاده کن.

بولین: از NUMBER(1) تا نوع بومی

پیش از 23ai، Oracle در SQL نوع boolean نداشت (فقط در PL/SQL). الگوی رایج، ستونِ NUMBER(1) با قید بود. PostgreSQL از ابتدا BOOLEAN بومی داشته:

CREATE TABLE account (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  is_active boolean NOT NULL DEFAULT false
);
SELECT * FROM account WHERE is_active;      -- خودِ ستون یک عبارتِ بولین است
قابلیت حمل بولین با Hibernate

اگر روی 19c هستی و می‌خواهی کد Java یک boolean واقعی ببیند، Hibernate boolean جاوا را روی Oracle به NUMBER(1)/0,1 و روی PostgreSQL به boolean بومی نگاشت می‌کند؛ entity ات private boolean active; می‌ماند و لایه‌ی دیالکت تفاوت را می‌پوشاند. فقط در native queryهای خام باید حواست به 1/0 باشد. تفاوت ظریف دیگر: در PostgreSQL می‌توانی WHERE is_active بنویسی، ولی در Oracle تا پیش از 23ai هیچ عبارتِ بولینی در SQL وجود ندارد و همیشه باید یک مقایسه بنویسی.

NULL و شرط‌ها: NVL، NVL2، DECODE در برابر COALESCE و CASE

هر دو COALESCE و CASE استاندارد را دارند؛ Oracle چند افزونه‌ی محبوب هم دارد:

کار Oracle (اختصاصی) استاندارد (هر دو)
مقدار جایگزین NULL NVL(a, b) COALESCE(a, b, …)
اگر NULL نبود x وگرنه y NVL2(a, x, y) CASE WHEN a IS NOT NULL THEN x ELSE y END
ترجمه‌ی مقدارها DECODE(v, 1,'A', 2,'B', 'other') CASE v WHEN 1 THEN 'A' … ELSE 'other' END
اگر برابر بود NULL کن NULLIF(a, b) NULLIF(a, b) (هر دو)
-- فرم استاندارد: در Oracle هم دقیقاً همین کار می‌کند
SELECT COALESCE(phone, mobile, 'N/A') AS contact,
       CASE status WHEN 'A' THEN 'Active'
                   WHEN 'I' THEN 'Inactive'
                   ELSE 'Unknown' END AS status_label
FROM   customer;
همیشه COALESCE و CASE بنویس

NVL فقط دو آرگومان می‌گیرد؛ COALESCE هر تعداد و اولین غیر-NULL را برمی‌گرداند. مهم‌تر: COALESCE تنبل است و به‌محض یافتن اولین غیر-NULL بقیه را ارزیابی نمی‌کند، ولی NVL هر دو آرگومان را ارزیابی می‌کند — پس NVL(x, expensive_function()) تابع گران را حتی وقتی x غیر-NULL است اجرا می‌کند. برای قابلیت حمل و کارایی، پیش‌فرضت COALESCE/CASE باشد؛ DECODE را فقط در کد قدیمی نگه‌دار.

DECODE و مقایسه‌ی NULL

DECODE مقدار NULL را با NULL برابر می‌داند (DECODE(x, NULL, 'was null', …) کار می‌کند)، ولی CASE x WHEN NULL هرگز TRUE نمی‌شود چون x = NULL همیشه UNKNOWN است. اگر کد قدیمیِ Oracle را به CASE ترجمه می‌کنی، حالت NULL را با CASE WHEN x IS NULL THEN … بنویس وگرنه رفتار عوض می‌شود و باگِ خاموش می‌سازی.

ترتیب NULL در ORDER BY — این‌جا هر دو یکسان‌اند

باور غلط رایج: خیلی‌ها فکر می‌کنند ترتیبِ پیش‌فرضِ NULL در دو موتور فرق دارد. واقعیت: هر دو در ASC مقدار NULL را آخر می‌گذارند (NULLS LAST) و در DESC اول، و هر دو NULLS FIRST/NULLS LAST صریح را می‌پذیرند. آنچه فرق دارد MySQL و SQL Server است که NULL را کوچک‌ترین مقدار می‌بینند.

رشته‌ها: اتصال، برش، جست‌وجو

اتصال با || در هر دو کار می‌کند و قابل‌حمل‌ترین راه است؛ برش و جست‌وجو فرق دارند:

SELECT first_name || ' ' || last_name AS full_name FROM person;
SELECT SUBSTRING('PostgreSQL' FROM 1 FOR 4);   -- 'Post' (استاندارد)
SELECT substr('PostgreSQL', 1, 4);             -- 'Post'
SELECT POSITION('@' IN 'a@b.com');             -- 2 (استاندارد)
SELECT strpos('a@b.com', '@');                 -- 2
INSTR قدرتمندتر است، ولی قابل‌حمل نیست

INSTR در Oracle نقطه‌ی شروع و n-اُمین وقوع را هم می‌گیرد (INSTR(str, sub, start, occurrence)POSITION استاندارد این‌ها را ندارد و PostgreSQL هم INSTR ندارد، پس INSTR(x, y, 1, 2) را باید با regex بازسازی کنی. CONCAT هم دام دارد: در Oracle فقط دو آرگومان می‌گیرد و در PostgreSQL چندآرگومانی است — || بنویس. و یک تفاوت واقعاً نتیجه‌عوض‌کن: در Oracle به‌خاطر تله‌ی رشته‌ی خالی NULL || 'x' می‌شود 'x'، ولی در PostgreSQL می‌شود NULL.

هر دو موتور regex دارند با نحوِ کاملاً متفاوت:

SELECT * FROM person WHERE email ~* '^[a-z0-9._%+-]+@example\.com$';
SELECT regexp_replace('a  b   c', '\s+', ' ', 'g');   -- 'a b c'

-- جست‌وجوی بی‌توجه به حروف با ایندکس تابعی (قابل حمل)
CREATE INDEX idx_person_email_lower ON person (lower(email));
SELECT * FROM person WHERE lower(email) = lower(:input);
collation: دو راه بومی و یک راه قابل حمل

هر دو راه بومی دارند — Oracle با NLS_COMP=LINGUISTIC + NLS_SORT=BINARY_CI یا COLLATE BINARY_CI روی ستون (از 12.2)، و PostgreSQL با افزونه‌ی citext یا collationِ ICU غیرقطعی — ولی هر دو غیرقابل‌حمل‌اند و رفتار ایندکس را عوض می‌کنند. راهِ سنیور: ایندکسِ تابعی روی LOWER(col) و LOWER() در دو طرفِ مقایسه. نکته‌ی مهم دیگر: مرتب‌سازیِ رشته در PostgreSQL به LC_COLLATE و در Oracle به NLS_SORT وابسته است، پس خروجی ORDER BY name می‌تواند بین دو محیط فرق کند حتی با دادهٔ یکسان.

پردازش رشته را به لایه‌ی اپ ببر

منطق رشته‌ایِ پیچیده (پارس ایمیل، فرمت نام، استخراج توکن) در دیتابیس هم غیرقابل‌حمل است و هم به‌سختی تست می‌شود. اگر عملیات فقط برای نمایش است، در Java انجامش بده و دیتابیس را برای فیلتر و تجمیع نگه‌دار — جایی که نزدیکیِ به داده واقعاً مزیت می‌دهد. این هم قابلیت حمل را بالا می‌برد هم بار CPU دیتابیس را کم می‌کند، که گران‌ترین و سخت‌مقیاس‌پذیرترین منبع است.

تاریخ و زمان

«حالا» چند است؟

SELECT now();                 -- timestamptz، لحظه‌ی شروع تراکنش
SELECT clock_timestamp();     -- زمان واقعیِ دیوار، همین لحظه
SELECT CURRENT_TIMESTAMP;     -- استاندارد، معادل now()
SYSDATE در برابر now(): معنای «حالا» یکی نیست

SYSDATE زمانِ سرور دیتابیس را می‌دهد و هر بار تازه است. now()/CURRENT_TIMESTAMP در PostgreSQL زمانِ شروع تراکنش جاری را می‌دهد و در طول تراکنش ثابت می‌ماند — اگر تراکنش ده ثانیه طول بکشد، now() ده بار یک مقدار می‌دهد و برای مُهرِ واقعاً جاری باید clock_timestamp() بزنی. تفاوت دوم در Oracle: SYSDATE منطقه‌ی زمانیِ سرور را می‌دهد و CURRENT_TIMESTAMP منطقه‌ی زمانیِ session را؛ اگر اپ و دیتابیس در دو منطقه باشند این دو فرق می‌کنند و گزارش‌ها جابه‌جا می‌شوند.

فرمت و پارس در هر دو با TO_CHAR/TO_DATE انجام می‌شود و بیشترِ الگوها مشترک‌اند (YYYY, MM, DD, HH24, MI, SS)؛ برای مقادیر ثابت، literalهای استاندارد قابل‌حمل‌ترین‌اند:

SELECT to_char(now(), 'YYYY-MM-DD HH24:MI:SS');
SELECT to_date('2026-07-23', 'YYYY-MM-DD');
SELECT DATE '2026-07-23';                       -- literal استاندارد
SELECT TIMESTAMP '2026-07-23 14:30:00';

حسابِ تاریخ اما جایی است که گویش‌ها واقعاً از هم جدا می‌شوند:

SELECT now() + interval '1 day';          -- فردا
SELECT now() + interval '1 month';        -- معادل ADD_MONTHS
SELECT date_trunc('month', now());        -- اول ماه
SELECT age(now(), created_at) FROM customer;
SELECT (end_at - start_at) FROM job_run;  -- تفاضل = interval
تفاضل تاریخ‌ها: عدد در برابر interval

در Oracle date2 - date1 یک عدد به واحد روز می‌دهد (0.5 یعنی ۱۲ ساعت) و SYSDATE + 1 یعنی فردا. در PostgreSQL تفاضلِ دو timestamp یک interval است و now() + 1 اصلاً خطاست. پس کدِ WHERE created_at > SYSDATE - 7 باید بشود WHERE created_at > now() - interval '7 days' — یکی از پرتکرارترین شکست‌های مهاجرت. EXTRACT در هر دو استاندارد است ولی در Oracle روی نوع DATE فقط YEAR/MONTH/DAY جواب می‌دهد و برای HOUR باید اول CAST(d AS TIMESTAMP) بزنی.

فرمت‌بندی تاریخ کارِ لایه‌ی نمایش است

تاریخ را در دیتابیس به رشته تبدیل نکن مگر مجبوری. timestamptz/Instant خام را به Java بده و با DateTimeFormatter و منطقه‌ی کاربر فرمت کن. TO_CHAR در WHERE هم قابلیت حمل را می‌شکند، هم ایندکس روی ستون تاریخ را بی‌اثر می‌کند، هم منطقه‌ی زمانی را در سطح دیتابیس قفل می‌کند.

عملگرهای مجموعه‌ای: MINUS در برابر EXCEPT

UNION، UNION ALL و INTERSECT در هر دو یکسان‌اند؛ تفاوت در «تفریق» است:

SELECT sku FROM inventory
EXCEPT                      -- استاندارد ANSI
SELECT sku FROM discontinued;

SELECT sku FROM inventory
EXCEPT ALL                  -- بدون حذف تکراری‌ها
SELECT sku FROM discontinued;
MINUS/EXCEPT و نسخه‌ها

تا Oracle 19c فقط MINUS وجود دارد و EXCEPT خطا می‌دهد؛ از 21c به بعد Oracle EXCEPT، EXCEPT ALL، MINUS ALL و INTERSECT ALL را هم دارد. PostgreSQL از ابتدا EXCEPT/EXCEPT ALL داشته و MINUS را ندارد. اگر باید 19c را هم پشتیبانی کنی این یکی از معدود جاهایی است که هیچ نحوِ مشترکی نیست — یا دو نسخه نگه‌دار، یا با NOT EXISTS بازنویسی کن که در هر دو یکسان کار می‌کند و معمولاً plan بهتری هم می‌گیرد.

صفحه‌بندی: سه نسل تاریخ

صفحه‌بندی جایی است که تفاوت‌ها آشکارترند. راه بومیِ هر کدام:

-- صفحه‌ی سوم، هر صفحه 20 ردیف
SELECT id, name FROM product
ORDER  BY created_at DESC, id DESC
LIMIT  20 OFFSET 40;

-- PostgreSQL فرم استاندارد را هم دارد
SELECT id, name FROM product
ORDER BY created_at DESC, id DESC
OFFSET 40 ROWS FETCH FIRST 20 ROWS ONLY;
یک نحو برای هر دو

OFFSET n ROWS FETCH FIRST m ROWS ONLY استانداردِ ANSI است و در هر دو کار می‌کند (Oracle 12c+ و PostgreSQL). اگر می‌خواهی یک کوئریِ صفحه‌بندیِ دستی بنویسی که روی هر دو اجرا شود، همین را بنویس و LIMIT را فراموش کن. FETCH FIRST … ROWS WITH TIES هم در هر دو هست و ردیف‌های هم‌ارزشِ مرز را هم می‌آورد.

Oracle قدیمی (11g و پیش‌تر) FETCH FIRST نداشت و باید با ROWNUM کار می‌کردی:

-- PostgreSQL شبه‌ستون ROWNUM ندارد؛ معادلش row_number() است
SELECT * FROM (
  SELECT p.*, row_number() OVER (ORDER BY created_at DESC, id DESC) AS rn
  FROM   product p
) t
WHERE rn > 40 AND rn <= 60;
چرا ROWNUM انقدر سخت است

ROWNUM قبل از ORDER BY تخصیص می‌یابد، نه بعد. پس WHERE ROWNUM <= 20 ORDER BY x بیست ردیفِ تصادفی می‌گیرد و بعد مرتب‌شان می‌کند — نه بیست ردیفِ اولِ مرتب! برای همین باید ORDER BY را در ساب‌کوئریِ درونی حبس کنی و ROWNUM را در لایه‌ی بیرونی بزنی. هر جا کدی دیدی که مستقیم WHERE ROWNUM < n ORDER BY … نوشته و انتظار «n تای اول» دارد، آن یک باگ است. از 12c به بعد این الگو را با FETCH FIRST بازنشسته کن.

برای صفحه‌بندیِ عمیق، هر سه روش بالا فاجعه‌اند و باید سراغِ keyset pagination (روش seek) بروی:

-- مقایسه‌ی تاپلی: PostgreSQL دارد و از ایندکس مرکب هم استفاده می‌کند
SELECT id, name FROM product
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
Oracle مقایسه‌ی تاپلی با `<` را پشتیبانی نمی‌کند

تفاوتی کم‌گفته‌شده و پرمصرف: در PostgreSQL WHERE (a, b) < (:a, :b) مجاز است و planner آن را روی ایندکس مرکبِ (a, b) خوب اجرا می‌کند. در Oracle، row value constructor فقط با =، <> و IN مجاز است و با < خطای ORA-00920: invalid relational operator می‌گیری؛ پس باید فرمِ بازشده‌ی a < :a OR (a = :a AND b < :b) را بنویسی و مراقب باشی ترتیب ستون‌های ایندکس دقیقاً (created_at, id) باشد وگرنه ایندکس را از دست می‌دهی. jOOQ این تفاوت را برایت مدیریت می‌کند.

OFFSET بزرگ کُشنده است

با OFFSET بزرگ، دیتابیس باید همه‌ی ردیف‌های تا offset را بخواند و دور بریزد؛ OFFSET 1000000 LIMIT 20 یعنی یک میلیون ردیف اسکن و دور ریختن. keyset با ایندکس روی (created_at, id) فارغ از عمق سریع می‌ماند و مزیت دومی هم دارد: اگر بین دو صفحه ردیفی درج یا حذف شود، keyset ردیف تکراری یا گم‌شده نشان نمی‌دهد ولی offset می‌دهد. «بارگذاری بیشتر» و اسکرول بی‌نهایت باید همیشه keyset باشند.

نمودار: انتخاب استراتژی صفحه‌بندی. | Choosing a pagination strategy.

flowchart TD
  Start[Need a page of rows] --> Deep{Deep offset or infinite scroll?}
  Deep -->|Yes| Keyset[Keyset / seek: WHERE key < last_key]
  Deep -->|No, shallow paging| Portable{Need portability?}
  Portable -->|Yes| Ansi[OFFSET ... FETCH FIRST ... ROWS ONLY]
  Portable -->|Oracle 11g legacy| Rownum[ROWNUM triple-nest]
  Portable -->|PostgreSQL only| Limit[LIMIT ... OFFSET]
Spring Data صفحه‌بندی را برایت می‌نویسد

Pageable را به repository بده و Hibernate با توجه به Dialect خودش LIMIT/OFFSET یا FETCH FIRST مناسب را تولید می‌کند؛ یعنی repo.findAll(PageRequest.of(2, 20, Sort.by("createdAt").descending())) روی هر دو کار می‌کند. فقط برای صفحه‌بندیِ عمیق، Slice (بدون count کوئریِ گران) یا keyset دستی را در نظر بگیر.

تفاوت ROWNUM و FETCH FIRST را توضیح بده و بگو چرا ROWNUM با ORDER BY خطرناک است.

ROWNUM یک شبه‌ستون Oracle است که به هر ردیف در لحظه‌ی بازیابی و پیش از ORDER BY شماره می‌دهد؛ پس WHERE ROWNUM <= 10 ORDER BY x اول ۱۰ ردیفِ دلخواه را می‌گیرد و بعد مرتب می‌کند — نه ۱۰ ردیفِ برتر. برای درست‌کارکردن باید ORDER BY را در ساب‌کوئری بگذاری و ROWNUM را بیرون بزنی. در مقابل، OFFSET … FETCH FIRST … ROWS ONLY استانداردِ ANSI است (Oracle 12c+ و PostgreSQL)، بعد از مرتب‌سازی اعمال می‌شود و OFFSET بومی دارد. PostgreSQL اصلاً ROWNUM ندارد و معادلش row_number() است که یک window function و بعد از مرتب‌سازی است. در کد جدید همیشه FETCH FIRST یا keyset؛ ROWNUM را فقط برای فهم کد قدیمی نگه‌دار.

شمارنده و کلید خودافزا

استاندارد مدرن در هر دو، GENERATED … AS IDENTITY است (PostgreSQL 10+، Oracle 12c+):

CREATE TABLE orders (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  total  numeric(12,2) NOT NULL
);
-- حالت‌ها: ALWAYS | BY DEFAULT
INSERT INTO orders (total) VALUES (99.90);

GENERATED ALWAYS اجازه‌ی مقدارِ دستی نمی‌دهد و GENERATED BY DEFAULT می‌دهد؛ حالت سومِ BY DEFAULT ON NULL (تولید فقط با NULLِ صریح) مالِ Oracle است. sequence صریح هم در هر دو هست ولی نحوِ فراخوانی فرق دارد:

-- nextval/currval توابع‌اند و نام sequence رشته است
CREATE SEQUENCE seq_order START 1 INCREMENT 1 CACHE 100;
INSERT INTO orders (id, total) VALUES (nextval('seq_order'), 99.90);
SELECT currval('seq_order');
-- پیش‌فرض CACHE در PostgreSQL برابر 1 است
CACHE در sequence و «سوراخ» در شماره‌ها

CACHE n سرعت را بالا می‌برد چون هر instance بلوکی از شماره‌ها را از پیش می‌گیرد، ولی اگر دیتابیس ری‌استارت شود یا instance بمیرد، بلوکِ مصرف‌نشده از دست می‌رود و شکاف می‌افتد. هرگز فرض نکن idها پیوسته‌اند. پیش‌فرض‌ها هم فرق دارند: Oracle CACHE 20 و PostgreSQL CACHE 1 — برای همین تیم‌ها بعد از مهاجرت به Oracle ناگهان «پریدنِ شماره‌ها» را باگ می‌دانند. در RAC چند-نودی بدون ORDER حتی ترتیب صعودی هم تضمین نیست.

بعد از بارگذاریِ داده با idهای صریح باید شمارنده را جلو ببری وگرنه اولین insertِ بعدی با کلید تکراری می‌شکند:

SELECT setval(pg_get_serial_sequence('orders', 'id'),
              (SELECT COALESCE(MAX(id), 0) FROM orders));
SELECT setval('seq_order', 1000, true);   -- برای sequence مستقل
@GeneratedValue: استراتژی درست را انتخاب کن

@GeneratedValue(strategy = GenerationType.IDENTITY) روی PostgreSQL کار می‌کند ولی batch insert را می‌شکند، چون Hibernate برای گرفتن id مجبور است هر INSERT را جدا اجرا کند. برای throughput بالا GenerationType.SEQUENCE با @SequenceGenerator(allocationSize = 50) بگذار تا Hibernate بلوکی id بگیرد و insertها را دسته کند. روی Oracle هم identity زیر پرده sequence است. قاعده‌ی سنیور: برای درج انبوه SEQUENCE، و allocationSize را با CACHE دیتابیس هماهنگ کن.

فرق GENERATED ALWAYS AS IDENTITY و GENERATED BY DEFAULT چیست و کِی کدام؟

ALWAYS اجازه نمی‌دهد در INSERT برای ستون مقدار بدهی؛ دیتابیس همیشه تولید می‌کند — امن‌ترین حالت که جلوی درجِ دستیِ اشتباه را می‌گیرد و برای کلید اصلیِ خالص عالی است. BY DEFAULT اجازه‌ی مقدارِ صریح می‌دهد و برای مهاجرت داده لازم است، ولی خطرش این است که شمارنده‌ی داخلی از مقادیر دستی عقب بماند و بعداً برخورد کلید بدهد؛ بعد از بارگذاری باید در PostgreSQL با setval() و در Oracle با ALTER TABLE … MODIFY (… START WITH LIMIT VALUE) جلو ببری‌اش. Oracle حالت سومِ BY DEFAULT ON NULL را هم دارد که PostgreSQL ندارد. توصیه: پیش‌فرض ALWAYS، و BY DEFAULT فقط برای ابزار مهاجرت.

Upsert: ON CONFLICT در برابر MERGE

«upsert» یعنی «اگر بود آپدیت کن، اگر نبود درج کن» — یکی از پرکاربردترین عملیات‌ها، با نحوی کاملاً متفاوت:

-- PostgreSQL: upsert روی کلید یکتا (از 9.5)
INSERT INTO inventory (sku, qty, updated_at)
VALUES ('ABC-1', 10, now())
ON CONFLICT (sku)
DO UPDATE SET qty        = inventory.qty + EXCLUDED.qty,
              updated_at = EXCLUDED.updated_at
RETURNING id, qty;
-- DO NOTHING هم داری اگر بخواهی برخورد را بی‌صدا رد کنی

در PostgreSQL EXCLUDED ردیفِ پیشنهادیِ درج است؛ در Oracle جدولِ منبع (USING … s) همان نقش را دارد.

MERGE در هر دو هست، ON CONFLICT فقط PostgreSQL

MERGE استانداردِ ANSI است و PostgreSQL هم از نسخه‌ی 15 آن را دارد، پس می‌توانی MERGE را به‌عنوان راهِ قابل‌حملِ upsert روی هر دو بنویسی؛ ولی ON CONFLICT فقط PostgreSQL است. دو تفاوت نحوی که در انتقال کد گاز می‌گیرد: (۱) Oracle شرط ON را حتماً داخل پرانتز می‌خواهد؛ (۲) در Oracle در UPDATE SET نمی‌توانی ستونی را که در شرط ON آمده تغییر دهی (ORA-38104)، در PostgreSQL چنین محدودیتی نیست.

MERGE و شرایط رقابتی (race condition)

MERGE به‌خودیِ‌خود ضدِّ همزمانی نیست. اگر دو تراکنش هم‌زمان برای یک کلیدِ ناموجود MERGE بزنند، هر دو ممکن است شاخه‌ی NOT MATCHED را بروند و بعد یکی با خطای unique شکست بخورد (یا در بدترین حالت دو ردیف بماند). راه درست: UNIQUE روی کلید + retry در اپ، یا قفلِ صریح. در PostgreSQL INSERT … ON CONFLICT این رقابت را اتمیک حل می‌کند و برای upsertِ پرتراکنش امن‌تر است. این تفاوت را در مصاحبه بلد باش — نشانه‌ی درک عمیقِ همزمانی است.

ORA-30926: منبعِ MERGE نباید کلید تکراری داشته باشد

اگر کوئریِ USING برای یک ردیفِ مقصد بیش از یک ردیفِ منطبق تولید کند، Oracle با ORA-30926: unable to get a stable set of rows in the source tables می‌ترکد چون نمی‌داند کدام مقدار را بنویسد. راه‌حل: منبع را قبل از merge با GROUP BY یا ROW_NUMBER() … WHERE rn = 1 یکتا کن. PostgreSQL هم رفتار مشابه دارد («MERGE command cannot affect row a second time»). پس یکتاسازیِ منبع در هر دو بخشی از طراحیِ درست است، نه یک اختیار.

از PG17 و Oracle 23ai می‌توانی نتیجه‌ی هر ردیف را در همان دستور بگیری:

-- PostgreSQL 17: MERGE با RETURNING و merge_action()
MERGE INTO inventory t
USING (VALUES ('ABC-1', 10)) AS s(sku, qty)
ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
RETURNING merge_action() AS action, t.sku, t.qty;
RETURNING در MERGE — تازه‌واردها

PostgreSQL 17 به MERGE قابلیت RETURNING اضافه کرد به‌همراه تابع merge_action() که می‌گوید هر ردیف INSERT شد یا UPDATE یا DELETE (و کلازِ WHEN NOT MATCHED BY SOURCE را هم آورد). Oracle 23ai هم MERGE … RETURNING را با کلیدواژه‌های OLD و NEW آورد. پیش از این نسخه‌ها برای دانستن نتیجه‌ی هر ردیف باید کوئریِ جدا می‌زدی. برد بزرگی برای کدِ تک‌رفت‌وبرگشتی است — ولی اگر باید 19c را هم پشتیبانی کنی، این کد آن‌جا کامپایل نمی‌شود.

نمودار: ماشین تصمیم MERGE. | The MERGE decision machine.

flowchart TD
  Row[Source row] --> Match{Key matches target?}
  Match -->|Yes| Upd[WHEN MATCHED -> UPDATE or DELETE]
  Match -->|No| Ins[WHEN NOT MATCHED -> INSERT]
  Upd --> Ret[RETURNING + merge_action]
  Ins --> Ret
  Ret --> Done[Rows affected]
upsert را در Oracle و PostgreSQL چطور می‌نویسی و کدام برای همزمانی امن‌تر است؟

در PostgreSQL از INSERT … ON CONFLICT (key) DO UPDATE SET … EXCLUDED… استفاده می‌کنم که اتمیک است و رقابتِ درجِ هم‌زمان را امن حل می‌کند. در Oracle MERGE INTO … USING … ON … WHEN MATCHED … WHEN NOT MATCHED … می‌نویسم. MERGE استاندارد است و از PostgreSQL 15 روی هر دو کار می‌کند، پس برای قابلیت حمل بهترش می‌دانم؛ اما به‌تنهایی ضدِّ رقابت نیست و دو تراکنش می‌توانند هر دو شاخه‌ی NOT MATCHED بروند و یکی با unique violation بشکند — پس همیشه UNIQUE می‌گذارم و در اپ retry می‌کنم. دو نکته‌ی تکمیلی که امتیاز می‌آورد: در Oracle باید منبع را یکتا کنم وگرنه ORA-30926 می‌گیرم، و روی PG17/Oracle 23ai با RETURNINGmerge_action()) نتیجه را در همان یک رفت‌وبرگشت می‌گیرم.

RETURNING: نتیجه را در همان دستور بگیر

RETURNING یعنی بعد از INSERT/UPDATE/DELETE، مقادیرِ ردیف‌های تغییریافته (مثل idِ تولیدشده) را بدون کوئریِ دوم برگردانی:

-- RETURNING یک result set واقعی می‌دهد
INSERT INTO orders (total) VALUES (99.90) RETURNING id, created_at;
UPDATE orders SET total = total * 1.1 WHERE id = 5 RETURNING id, total;
DELETE FROM orders WHERE id = 5 RETURNING id;
Oracle 23ai و بهبود RETURNING

Oracle 23ai کلاز RETURNING را قوی‌تر کرد: حالا برای UPDATE و MERGE هم کار می‌کند و با OLD/NEW به مقدارِ قبل و بعد دسترسی می‌دهد. ولی یک تفاوتِ ساختاری می‌ماند: در PostgreSQL خروجی یک مجموعه‌ی نتیجه است که مستقیم با executeQuery() می‌خوانی و روی چند ردیف کار می‌کند؛ در Oracle خروجی به bind variable می‌رود و برای چند ردیف باید RETURNING … BULK COLLECT INTO بنویسی. در JDBC هر دو کلید تولیدشده را با getGeneratedKeys() هم می‌دهند.

نمودار: گرفتن کلید تولیدشده در Spring/JDBC. | Fetching a generated key in Spring/JDBC.

sequenceDiagram
  participant App as Spring Service
  participant JT as JdbcTemplate
  participant DB as DB (PG/Oracle)
  App->>JT: update(INSERT, keyHolder)
  JT->>DB: INSERT ... (generated key column)
  DB-->>JT: generated id
  JT-->>App: keyHolder.getKey()
// Spring: گرفتن کلید تولیدشده به‌شکل قابل‌حمل بین PostgreSQL و Oracle
public long insertOrder(BigDecimal total) {
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcTemplate.update(connection -> {
        PreparedStatement ps = connection.prepareStatement(
            "INSERT INTO orders (total) VALUES (?)",
            new String[] { "id" });   // نام ستون کلید — روی هر دو کار می‌کند
        ps.setBigDecimal(1, total);
        return ps;
    }, keyHolder);
    return keyHolder.getKey().longValue();
}
`new String[]{"id"}` را با `RETURN_GENERATED_KEYS` اشتباه نگیر

روی Oracle اگر Statement.RETURN_GENERATED_KEYS بدهی، درایور ROWID را برمی‌گرداند نه ستون id، و کدت با خطای تبدیل نوع یا مقدار عجیب می‌شکند. راهِ درست و قابل‌حمل همان است که در کد بالا دیدی: نامِ ستون کلید را صریح بده. روی PostgreSQL هم همین فرم کار می‌کند و درایور آن را به RETURNING id ترجمه می‌کند. این یکی از آن جزئیاتی است که فقط وقتی کد را از PostgreSQL به Oracle می‌بری کشف می‌شود.

در JDBC/Spring از getGeneratedKeys استفاده کن

اگر فقط کلیدِ تولیدشده را می‌خواهی، سراغِ RETURNINGِ دستیِ گویش‌محور نرو؛ با JdbcTemplate یک KeyHolder بده و ستون کلید را نام ببر. برای entityها هم @GeneratedValue همین را پشت پرده انجام می‌دهد. RETURNING صریح را فقط وقتی بنویس که چند ستون یا مقادیر محاسبه‌شده می‌خواهی و روی یک دیتابیس قفل شده‌ای.

توابع پنجره‌ای (analytic/window)

خبر عالی: توابع پنجره‌ای استاندارد SQL‌اند و در هر دو یکسان کار می‌کنند:

SELECT product_id, category, sales,
       RANK()     OVER (PARTITION BY category ORDER BY sales DESC) AS rk,
       SUM(sales) OVER (PARTITION BY category)                     AS cat_total,
       LAG(sales) OVER (PARTITION BY category ORDER BY month)      AS prev_month
FROM   product_sales;
window functions ابزار مخفیِ سنیورهاست

خیلی از منطق‌هایی که junior با چند کوئری و حلقه‌ی Java حل می‌کند (رتبه‌بندی، «آخرین رکورد هر گروه»، جمع تجمعی، تفاوت با ردیف قبل)، با یک window function در یک کوئریِ قابل‌حمل حل می‌شود. یاد گرفتنش تفاوتِ آشکارِ mid و senior در مصاحبه است.

DISTINCT ON و FILTER فقط PostgreSQL

PostgreSQL دو میان‌بُر دارد که Oracle ندارد: DISTINCT ON برای «اولین ردیفِ هر گروه» و کلازِ FILTER برای تجمیعِ شرطی. معادل‌های قابل‌حمل، ROW_NUMBER() و COUNT(CASE …) هستند:

SELECT DISTINCT ON (user_id) * FROM events ORDER BY user_id, ts DESC;

SELECT COUNT(*) AS total,
       COUNT(*) FILTER (WHERE status = 'PAID') AS paid
FROM   orders;

فرمِ COUNT(CASE …) در هر دو کار می‌کند، پس اگر قابلیت حمل مهم است همان را بنویس.

تجمیع رشته: LISTAGG در برابر STRING_AGG

«تبدیل چند ردیف به یک رشته‌ی جداشده با کاما» — کارِ رایج در گزارش‌ها:

SELECT department_id,
       STRING_AGG(last_name, ', ' ORDER BY last_name) AS names
FROM   employees
GROUP  BY department_id;
سرریز LISTAGG و محدودیت ۴۰۰۰ بایت

LISTAGG اگر نتیجه از حدِّ VARCHAR2 (۴۰۰۰ بایت، یا ۳۲۷۶۷ با extended) بگذرد با ORA-01489: result of string concatenation is too long می‌ترکد — در گزارش‌های واقعی روی گروه‌های بزرگ زیاد اتفاق می‌افتد. کلازِ ON OVERFLOW TRUNCATE (از 12c R2) را بگذار تا به‌جای خطا کوتاه کند. STRING_AGG چون به TEXT تجمیع می‌کند این سقف را ندارد. تفاوت نحوی هم مهم است: ترتیب در Oracle داخل WITHIN GROUP می‌آید و در PostgreSQL داخل خودِ تابع.

چند ردیف را به یک رشته‌ی جداشده با کاما تبدیل کن — در هر دو دیتابیس.

در PostgreSQL: STRING_AGG(col, ', ' ORDER BY col). در Oracle: LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col) با GROUP BY. تفاوت‌هایی که باید ذکر کنم: (۱) LISTAGG سقفِ ۴۰۰۰/۳۲۷۶۷ بایتِ VARCHAR2 دارد و روی گروه بزرگ ORA-01489 می‌دهد، پس ON OVERFLOW TRUNCATE می‌گذارم؛ STRING_AGG روی TEXT این محدودیت را ندارد. (۲) نحوِ ORDER BY فرق دارد. (۳) LISTAGG DISTINCT از 19c هست ولی در نسخه‌های قدیمی‌تر باید شبیه‌سازی‌اش کنی، در حالی‌که STRING_AGG(DISTINCT …) همیشه بوده. (۴) اگر قابلیت حمل مهم باشد، این یکی از جاهایی است که مجبوری دو نسخه نگه‌داری یا به لایه‌ی اپ ببری.

کوئری‌های سلسله‌مراتبی: CONNECT BY در برابر recursive CTE

درخت سازمانی، دسته‌بندیِ تودرتو، BOM — هر جا ردیف‌ها به والدِ خودشان ارجاع می‌دهند. Oracle یک نحوِ اختصاصیِ قدیمی دارد که در هیچ دیتابیس دیگری نیست:

-- استاندارد ANSI: WITH RECURSIVE (کلیدواژه‌ی RECURSIVE اجباری است)
WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS lvl, name::text AS path
  FROM   employee WHERE manager_id IS NULL          -- ریشه
  UNION ALL
  SELECT e.id, e.name, e.manager_id, o.lvl + 1, o.path || '/' || e.name
  FROM   employee e
  JOIN   org o ON e.manager_id = o.id               -- گام بازگشتی
)
SELECT lvl, path, name FROM org ORDER BY path;
CONNECT BY را بفهم، ولی جدید ننویس

CONNECT BY مزیت‌های واقعی دارد — LEVEL، SYS_CONNECT_BY_PATH، CONNECT_BY_ROOT، CONNECT_BY_ISLEAF، ORDER SIBLINGS BY و NOCYCLE — که همه در recursive CTE باید دستی ساخته شوند. ولی Oracle از 11gR2 فرمِ استاندارد را هم دارد، پس قاعده‌ی سنیور این است: کدِ قدیمیِ CONNECT BY را بفهم و دست نزن، ولی هر کوئری سلسله‌مراتبیِ جدید را با recursive CTE بنویس تا در هر دو اجرا شود. تله‌ی اول انتقال: کلیدواژه‌ی RECURSIVE در PostgreSQL اجباری است و در Oracle نوشته نمی‌شود.

یک درخت سازمانی را در Oracle و PostgreSQL چطور پیمایش می‌کنی؟

در PostgreSQL فقط یک راه دارم: WITH RECURSIVE با یک عضو پایه (ریشه‌ها) و یک عضو بازگشتی که به خودِ CTE جوین می‌شود. در Oracle دو راه دارم: CONNECT BY PRIOR … START WITH … که بومی و خیلی خلاصه است و LEVEL، CONNECT_BY_ROOT، SYS_CONNECT_BY_PATH و ORDER SIBLINGS BY را رایگان می‌دهد؛ و از 11gR2 همان WITH استاندارد. برای کد جدیدِ قابل‌حمل، recursive CTE می‌نویسم و سطح و مسیر را دستی می‌سازم. دو نکته‌ی عملی: کلیدواژه‌ی RECURSIVE در PostgreSQL اجباری است ولی در Oracle نه؛ و برای دادهٔ حلقه‌دار، Oracle NOCYCLE/CONNECT_BY_ISCYCLE دارد و PostgreSQL از نسخه‌ی ۱۴ کلازِ CYCLE … SET … USING … — بدون این‌ها کوئری تا بی‌نهایت می‌رود و سرور را می‌خواباند.

JSON در هر دو

هر دو JSON را جدی گرفته‌اند ولی مدل‌ها فرق دارد: PostgreSQL نوع jsonb (باینریِ ایندکس‌پذیر) دارد و Oracle از 21c نوع بومیِ JSON:

CREATE TABLE doc (id bigint GENERATED ALWAYS AS IDENTITY, body jsonb);

SELECT body ->> 'name'                AS name,      -- متن
       body -> 'address' ->> 'city'   AS city,      -- تودرتو
       body @> '{"active": true}'     AS is_active  -- شامل بودن
FROM   doc
WHERE  body @> '{"type": "customer"}';              -- از GIN index بهره می‌برد

CREATE INDEX idx_doc_body ON doc USING gin (body);

-- PostgreSQL 17: توابع استاندارد SQL/JSON
SELECT JSON_VALUE(body, '$.address.city') FROM doc WHERE JSON_EXISTS(body, '$.type');
SQL/JSON مشترک، عملگرها متفاوت

توابع استانداردِ JSON_VALUE، JSON_QUERY، JSON_TABLE و JSON_EXISTS حالا در هر دو هستند (PostgreSQL از ۱۷، Oracle از مدت‌ها پیش) و برای قابلیت حمل بهترند. اما عملگرهای میان‌بُرِ PostgreSQL (->, ->>, @>, #>) و نحوِ نقطه‌ایِ Oracle اختصاصی‌اند. ایندکس‌گذاری هم فرق دارد: GIN روی کل سند در PostgreSQL، در برابر CREATE SEARCH INDEX … FOR JSON یا ایندکس تابعی روی یک JSON_VALUE خاص در Oracle. اگر روی PostgreSQL 16 هستی هنوز JSON_TABLE نداری؛ آن از ۱۷ آمد.

JSON در دیتابیس رابطه‌ای را با احتیاط استفاده کن

JSON عالی است برای داده‌ی نیمه‌ساخت‌یافته و واقعاً متغیر (تنظیمات، payloadِ خام، ویژگی‌های پویا)، ولی وسوسه نشو همه‌چیز را در یک ستونِ jsonb بریزی. اگر روی فیلدی مرتب WHERE/JOIN/ORDER BY می‌زنی، آن باید ستونِ واقعیِ typed باشد نه داخل JSON — وگرنه ایندکس، قید و بهینه‌ساز به دردت نمی‌خورند. الگوی سنیور: فیلدهای «داغ» را به ستون بکش و بقیه‌ی «سردِ» متغیر را در jsonb نگه‌دار. JSON جایگزینِ طراحی اسکیما نیست.

جدول موقت: GTT در برابر TEMP TABLE

جدول موقت برای staging در ETL و گزارش‌های سنگین لازم می‌شود، و مدلِ ذهنیِ دو موتور کاملاً فرق دارد:

-- تعریف و داده هر دو موقت‌اند؛ در پایان session حذف می‌شود
CREATE TEMP TABLE stage_order (
  id bigint, total numeric(12,2)
) ON COMMIT DELETE ROWS;   -- یا ON COMMIT DROP

INSERT INTO stage_order SELECT id, total FROM orders
WHERE created_at > now() - interval '1 day';
ANALYZE stage_order;       -- برای plan درست
GTT را در migration بساز، نه در runtime

تفاوت مفهومی: در Oracle یک GLOBAL TEMPORARY TABLE را یک بار در migration می‌سازی و برای همیشه در دیکشنری می‌ماند؛ هر session فقط دادهٔ خودش را می‌بیند. در PostgreSQL هر session خودش CREATE TEMP TABLE می‌زند. اگر کدِ PostgreSQL که در هر تراکنش جدول موقت می‌سازد را به Oracle ببری، به‌ازای هر فراخوانی یک DDL می‌زنی — یعنی commit ضمنی، قفلِ دیکشنری و افتِ شدیدِ کارایی. برعکسش هم در PostgreSQL ساختنِ صدها هزار جدول موقت به pg_class bloat می‌دهد. در هر دو، بعد از پرکردنِ جدول موقت آمارش را بگیر وگرنه بهینه‌ساز کور تصمیم می‌گیرد.

تراکنش، قفل و ایزولاسیون

هر دو MVCC دارند («خواننده‌ها نویسنده‌ها را بلاک نمی‌کنند») ولی پیاده‌سازی‌شان بنیادین فرق دارد و همین، الگوی خرابی‌شان را در پروداکشن تعیین می‌کند: Oracle نسخه‌ی قدیمیِ ردیف را در UNDO tablespace نگه می‌دارد و اگر خواننده‌ی طولانی به undoِ بازنویسی‌شده برسد ORA-01555: snapshot too old می‌گیرد؛ PostgreSQL نسخه‌ی قدیمی را در خودِ جدول نگه می‌دارد و اگر VACUUM عقب بیفتد جدول و ایندکس باد می‌کنند (bloat).

-- سطوح: READ COMMITTED (پیش‌فرض)، REPEATABLE READ، SERIALIZABLE (واقعی، SSI)
BEGIN ISOLATION LEVEL REPEATABLE READ;
  SELECT SUM(total) FROM orders;
COMMIT;

VACUUM (ANALYZE) orders;
SELECT relname, n_dead_tup FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
Oracle سطح REPEATABLE READ ندارد

اگر در Spring بنویسی @Transactional(isolation = Isolation.REPEATABLE_READ) و روی Oracle اجرا کنی خطا می‌گیری، چون Oracle فقط READ COMMITTED، SERIALIZABLE و READ ONLY دارد؛ معادلِ کاربردیِ REPEATABLE READ همان SERIALIZABLE است (که در واقع snapshot isolation است و در تعارض ORA-08177 می‌دهد). PostgreSQL هر چهار نام را می‌پذیرد ولی READ UNCOMMITTED مثل READ COMMITTED رفتار می‌کند، REPEATABLE READ همان snapshot isolation است و SERIALIZABLE واقعاً serializable (SSI) و در تعارض 40001 می‌دهد. نتیجه‌ی عملی: هر تراکنشی بالاتر از READ COMMITTED باید در اپ قابل retry باشد — در هر دو موتور.

قفل صریح نحوِ مشترک دارد، با یک تفاوت کوچک ولی مهم:

-- صف کار: هر worker یک دسته ردیف بدون رقابت برمی‌دارد
SELECT id, payload FROM job_queue
WHERE  status = 'READY'
ORDER  BY created_at
FOR UPDATE SKIP LOCKED
FETCH FIRST 10 ROWS ONLY;

SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;   -- خطای فوری
SET lock_timeout = '5s';   -- PostgreSQL «WAIT n» ندارد
DDL در Oracle commit ضمنی می‌کند — در PostgreSQL نه

یکی از عمیق‌ترین تفاوت‌ها، با اثر مستقیم روی طراحیِ migration. در Oracle هر CREATE/ALTER/DROP قبل و بعد از خودش COMMIT ضمنی می‌زند؛ یعنی نمی‌توانی چند تغییرِ ساختاری را اتمیک برگردانی و اگر اسکریپت وسط کار بشکند، نیمی از تغییرات دائمی شده‌اند. در PostgreSQL تقریباً همه‌ی DDLها تراکنشی‌اند. نتیجه: در Oracle هر migration باید idempotent و قابل‌ازسرگیری نوشته شود؛ در PostgreSQL می‌توانی کلِ یک نسخه را در یک تراکنش بپیچی (و Flyway هم دقیقاً همین کار را می‌کند). استثنای مهمِ PostgreSQL: CREATE INDEX CONCURRENTLY داخل تراکنش اجرا نمی‌شود.

BEGIN;
  ALTER TABLE orders ADD COLUMN note text;
  CREATE INDEX idx_orders_note ON orders (note);
ROLLBACK;             -- هیچ اثری باقی نمی‌ماند
هر دو MVCC دارند؛ پس تفاوتشان در پروداکشن چیست؟

تفاوت در جای نگهداریِ نسخه‌ی قدیمی است. Oracle آن را در UNDO می‌نویسد، پس جدول تمیز می‌ماند ولی یک گزارشِ چندساعته می‌تواند به undoِ بازنویسی‌شده برسد و ORA-01555 snapshot too old بگیرد — درمانش بزرگ‌کردن undo، تنظیم UNDO_RETENTION یا کوتاه‌کردن تراکنش‌های خواننده است. PostgreSQL نسخه‌ها را در خودِ صفحه‌ی جدول نگه می‌دارد، پس خواننده‌ی طولانی خطا نمی‌گیرد ولی جلوی VACUUM را می‌گیرد، جدول و ایندکس bloat می‌شوند و در نهایت خطر transaction ID wraparound هست — درمانش autovacuumِ درست‌تنظیم‌شده و پرهیز از تراکنش‌های بازِ طولانی است. جمله‌ای که امتیاز می‌آورد: «در Oracle تراکنشِ طولانیِ خواننده به خودش آسیب می‌زند، در PostgreSQL به کلِ دیتابیس.» دو تفاوت تکمیلی: Oracle سطح REPEATABLE READ ندارد و DDL در آن commit ضمنی می‌کند.

کدهای خطا و ترجمه‌ی آن‌ها در Spring

اگر می‌خواهی کدت روی هر دو رفتارِ یکسان داشته باشد، باید بدانی یک قیدِ شکسته در هر موتور چه شکلی برمی‌گردد:

اتفاق Oracle PostgreSQL (SQLSTATE) استثنای Spring
نقض کلید یکتا ORA-00001 23505 DuplicateKeyException
نقض NOT NULL ORA-01400 23502 DataIntegrityViolationException
نقض کلید خارجی ORA-02291/ORA-02292 23503 DataIntegrityViolationException
نقض CHECK ORA-02290 23514 DataIntegrityViolationException
بن‌بست (deadlock) ORA-00060 40P01 DeadlockLoserDataAccessException
شکست سریال‌سازی ORA-08177 40001 CannotAcquireLockException
قفل در دسترس نیست ORA-00054 55P03 CannotAcquireLockException
مقدار بزرگ‌تر از ستون ORA-12899 22001 DataIntegrityViolationException
رشته‌ی غیرعددی ORA-01722 22P02 DataIntegrityViolationException

قید را نام‌دار بساز تا در هر دو بتوانی خطا را به پیام کاربری ترجمه کنی:

ALTER TABLE customer ADD CONSTRAINT uq_customer_email UNIQUE (email);
-- پیام: duplicate key value violates unique constraint "uq_customer_email"
بگذار Spring ترجمه کند، نه کدت

Spring خطاهای هر دو را به سلسله‌مراتبِ DataAccessException نگاشت می‌کند: برای PostgreSQL از روی SQLSTATE و برای Oracle از روی error codeهای sql-error-codes.xml. پس در سرویس هرگز روی ORA-00001 یا متنِ پیام شرط نگذار؛ catch (DuplicateKeyException e) بنویس تا کد روی هر دو کار کند. برای خطاهای گذرا (CannotAcquireLockException, DeadlockLoserDataAccessException) از Spring Retry با backoff استفاده کن. اگر نامِ قید را می‌خواهی، در Oracle حروف بزرگ برمی‌گردد و در PostgreSQL حروف کوچک — مقایسه را بی‌توجه به حروف بنویس.

یک سرویس باید هم روی Oracle هم PostgreSQL خطای «ایمیل تکراری» را تمیز مدیریت کند. چطور؟

به دیتابیس تکیه می‌کنم نه به یک SELECT قبلی، چون آن ذاتاً race دارد: یک UNIQUE نام‌دار می‌گذارم و درج را انجام می‌دهم. Oracle ORA-00001 و PostgreSQL SQLSTATE 23505 می‌دهد و Spring هر دو را به DuplicateKeyException نگاشت می‌کند، پس در سرویس فقط همان استثنا را می‌گیرم و به یک خطای دامنه‌ای تبدیل می‌کنم — بدون هیچ کد گویش‌محوری. اگر چند unique روی جدول باشد، نام قید را از استثنا می‌خوانم و بی‌توجه به بزرگی/کوچکیِ حروف مقایسه می‌کنم چون Oracle نام‌ها را UPPERCASE و PostgreSQL lowercase برمی‌گرداند. و برای خطاهای گذرا مثل deadlock (ORA-00060/40P01) یا شکست سریال‌سازی (ORA-08177/40001) لایه‌ی retry می‌گذارم، چون این‌ها باگ نیستند بلکه بخشِ طبیعیِ کارِ همزمان‌اند.

plan، آمار، hint و مانیتورینگ

وقتی کوئری کند می‌شود، ابزارِ نگاه‌کردن در دو موتور کاملاً متفاوت است:

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;        -- تخمینی

-- plan واقعی با زمان و I/O — این را بنویس
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE customer_id = 42;

ANALYZE orders;   -- تازه‌کردن آمار
تخمین در برابر واقعیت

EXPLAIN بدون ANALYZE و EXPLAIN PLAN هر دو فقط تخمینِ بهینه‌سازاند و ممکن است با آنچه واقعاً اجرا می‌شود فرق کنند — به‌خصوص در Oracle که به‌خاطر bind peeking و adaptive plans، planِ نمایش‌داده‌شده لزوماً planِ اجراشده نیست. آنچه در هر دو باید نگاه کنی یکی است: فاصله‌ی ردیف‌های تخمینی از واقعی. اگر بهینه‌ساز ۱۰ ردیف تخمین زده و ۱۰۰٬۰۰۰ ردیف برگشته، مشکل از آمار یا selectivity است، نه از ایندکس‌نداشتن.

بزرگ‌ترین تفاوت فلسفی این‌جاست: Oracle اجازه می‌دهد به بهینه‌ساز دستور بدهی، PostgreSQL نه:

-- PostgreSQL هیچ hintِ درون‌کوئری ندارد (تصمیم عمدیِ پروژه)
SET enable_seqscan = off;
SELECT * FROM orders WHERE customer_id = 42;
RESET enable_seqscan;
-- برای hintِ واقعی باید افزونه‌ی pg_hint_plan را نصب کنی
hint را درمانِ دائمی نکن — و مراقب لایسنس AWR باش

دو هشدارِ سنیور. اول: hint فوری جواب می‌دهد ولی planی را که به یک توزیعِ دادهْ گره خورده منجمد می‌کند؛ شش ماه بعد همان hint کوئری را کند می‌کند و چون در متنِ کد است کسی سراغش نمی‌رود. درمانِ درست معمولاً آمارِ به‌روز، ایندکسِ مناسب یا بازنویسیِ کوئری است. اگر کدِ hint‌دار را به PostgreSQL ببری، کامنت‌ها بی‌صدا نادیده گرفته می‌شوند و کوئری plan دیگری می‌گیرد — «کار می‌کند ولی کند است»، بدترین حالت. دوم و پرهزینه‌تر: AWR، ASH و DBA_HIST_* به لایسنس Diagnostics Pack نیاز دارند؛ اجرای awrrpt.sql روی دیتابیسی که آن را ندارد یعنی ریسک ممیزی. جایگزین‌های بی‌لایسنس: V$SQL، V$SESSION و Statspack. در PostgreSQL همه‌چیز آزاد است: pg_stat_statements، pg_stat_activity، auto_explain.

-- گران‌ترین کوئری‌ها (نیازمند افزونه‌ی pg_stat_statements)
SELECT calls, mean_exec_time, rows, query
FROM   pg_stat_statements ORDER BY total_exec_time DESC
FETCH FIRST 10 ROWS ONLY;

SELECT pid, state, wait_event_type, wait_event, query
FROM   pg_stat_activity WHERE state <> 'idle';
روی هر دو دیتابیس یک کوئریِ کند داری. مسیر عیب‌یابی‌ات چیست و چه فرقی می‌کند؟

مسیر فکری‌ام یکی است، ابزار فرق می‌کند. اول planِ واقعی را می‌گیرم: EXPLAIN (ANALYZE, BUFFERS) در PostgreSQL و GATHER_PLAN_STATISTICS + DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST') در Oracle. بعد دنبال فاصله‌ی تخمین از واقعیت می‌گردم و اگر زیاد بود آمار را تازه می‌کنم (ANALYZE در برابر DBMS_STATS.GATHER_TABLE_STATS). بعد سراغِ الگوی دسترسی می‌روم: full scan روی جدول بزرگ، nested loop با تکرارِ بالا، یا مرتب‌سازیِ روی دیسک (work_mem در برابر PGA_AGGREGATE_TARGET). برای الگوی سطح سیستم، pg_stat_statements در برابر V$SQL — و حواسم هست AWR/ASH لایسنس Diagnostics Pack می‌خواهد. تفاوت فلسفیِ آخر: در Oracle می‌توانم با hint یا SQL Plan Baseline planی را تثبیت کنم؛ در PostgreSQL چنین چیزی نیست و باید کوئری، ایندکس یا پارامترهای planner را درست کنم — که معمولاً درمانِ سالم‌تری هم هست.

PL/SQL در برابر PL/pgSQL

هر دو زبانِ رویه‌ای دارند و ظاهرشان شبیه است (هر دو از Ada الهام گرفته‌اند)، ولی جزئیات فرق دارد:

CREATE OR REPLACE FUNCTION order_total(p_id bigint)
RETURNS numeric
LANGUAGE plpgsql
AS $$
DECLARE
  v_total numeric := 0;
BEGIN
  SELECT COALESCE(SUM(qty * price), 0) INTO v_total
  FROM   order_line WHERE order_id = p_id;
  RETURN v_total;
EXCEPTION
  WHEN no_data_found THEN RETURN 0;
END;
$$;

SELECT order_total(42);
چه چیزی در PostgreSQL معادل ندارد

(۱) packageCREATE PACKAGE در Oracle یک نام‌فضا با متغیرهای session-scoped است؛ PostgreSQL package ندارد و با schema و توابعِ جدا شبیه‌سازی می‌شود. (۲) تراکنشِ خودمختارPRAGMA AUTONOMOUS_TRANSACTION (برای لاگ‌کردن حتی وقتی تراکنش اصلی rollback می‌شود) در PostgreSQL نیست؛ معادلش dblink یا انتقال لاگ به بیرون از دیتابیس است. (۳) PostgreSQL بدنه را با dollar-quoting و LANGUAGE plpgsql می‌گیرد. (۴) در Oracle تابعِ دارای خطای کامپایل با وضعیت INVALID ذخیره می‌شود (SHOW ERRORS بزن)، ولی PostgreSQL بدنه را تا زمان اجرا کامل چک نمی‌کند. (۵) از PostgreSQL 11 PROCEDURE هم داری که می‌تواند داخل خودش COMMIT بزند.

PL/SQL بزرگ‌ترین هزینه‌ی مهاجرت است

اگر منطقِ کسب‌وکار در پکیج‌های PL/SQL نشسته باشد، مهاجرت به PostgreSQL دیگر «تغییر گویشِ SQL» نیست، «بازنویسیِ یک برنامه» است. ابزارهایی مثل ora2pg بخش زیادی را خودکار می‌کنند ولی هرگز صددرصد نیستند. درسِ معماری برای امروز: منطقِ جدید را در لایه‌ی اپ نگه‌دار و دیتابیس را برای داده و یکپارچگی استفاده کن؛ آن‌وقت روزی که سؤالِ مهاجرت پیش بیاید، جوابش یک تصمیم است نه یک پروژه‌ی دوساله.

بارگذاری انبوه و batching در JDBC

-- سریع‌ترین راه: COPY (در psql با \copy از فایل سمت کلاینت)
COPY orders (id, customer_id, total)
FROM '/data/orders.csv' WITH (FORMAT csv, HEADER true);

ANALYZE orders;   -- بعد از بارگذاری انبوه، آمار را تازه کن
یک پارامتر که درج انبوه در PostgreSQL را چند برابر می‌کند

اگر با JDBC و addBatch() کار می‌کنی، درایورِ PostgreSQL به‌طور پیش‌فرض هر INSERT را جدا می‌فرستد؛ با افزودن reWriteBatchedInserts=true به JDBC URL، درایور آن‌ها را به یک INSERT … VALUES (…), (…) بازنویسی می‌کند و throughput معمولاً چند برابر می‌شود. در Oracle چنین سوییچی لازم نیست چون درایور آرایه‌ای می‌فرستد؛ به‌جایش defaultRowPrefetch را برای خواندن حجیم بالا ببر. در هر دو hibernate.jdbc.batch_size را ست کن و GenerationType.SEQUENCE بگذار وگرنه batching فعال نمی‌شود. و یادت باشد reWriteBatchedInserts رفتار خطا را عوض می‌کند: با یک دستورِ چندردیفی، اولین خطا کلِ گروه را بی‌اثر می‌کند.

استراتژی قابلیت حمل: چطور یک‌بار بنویسی و همه‌جا اجرا کنی

چهار لایه‌ی دفاعِ قابلیت حمل

۱. ORM/Hibernate با Dialect درست: بیشترِ CRUD، صفحه‌بندی، تولید کلید و نگاشت نوع را خودکار می‌کند. ۲. jOOQ برای SQL نوع‌امن: SQL پیچیده ولی قابل‌حمل تولید می‌کند (حتی مقایسه‌ی تاپلیِ keyset را برای Oracle باز می‌کند). ۳. Flyway/Liquibase برای migration: DDL را نسخه‌بندی می‌کند؛ Liquibase حتی changelogِ دیتابیس-اگنوستیک می‌سازد. ۴. Testcontainers برای تست روی هر دو: تستِ یکپارچگی را روی Oracle و PostgreSQL واقعی (نه H2) اجرا کن تا تفاوت‌ها را در CI بگیری نه در پروداکشن.

# application.yml — Hibernate خودش dialect را تشخیص می‌دهد،
# ولی صریح‌نوشتن برای وضوح بهتر است
spring:
  jpa:
    hibernate:
      ddl-auto: validate        # هرگز create/update در پروداکشن
    properties:
      hibernate:
        jdbc:
          batch_size: 50        # برای درج انبوه با SEQUENCE
        order_inserts: true
---
spring:
  config:
    activate:
      on-profile: postgres
  datasource:
    url: jdbc:postgresql://db-host:5432/app?reWriteBatchedInserts=true
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
---
spring:
  config:
    activate:
      on-profile: oracle
  datasource:
    url: jdbc:oracle:thin:@//db-host:1521/ORCLPDB1
  jpa:
    database-platform: org.hibernate.dialect.OracleDialect
H2 «حالت سازگاری» جای دیتابیس واقعی را نمی‌گیرد

یک اشتباه رایج: تست روی H2 با MODE=Oracle چون سریع است. H2 خیلی از رفتارهای واقعی (تله‌ی رشته‌ی خالی، جزئیاتِ MERGE، سطوح ایزولاسیون، commit ضمنیِ DDL، رفتار JSON، planِ کوئری) را بازتولید نمی‌کند؛ تستت سبز می‌شود ولی پروداکشن قرمز. با Testcontainers همان نسخه‌ی واقعی را در Docker بالا بیاور و تستِ یکپارچگی را رویش بزن. کندتر است ولی باگ‌های گویش را پیش از پروداکشن می‌گیرد — دقیقاً همان‌جایی که این باگ‌ها گران‌ترین‌اند.

// Testcontainers: همان تست، روی هر دو دیتابیسِ واقعی
@SpringBootTest
@Testcontainers
class OrderRepositoryPgTest {

    @Container
    static PostgreSQLContainer<?> pg =
        new PostgreSQLContainer<>("postgres:17-alpine");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", pg::getJdbcUrl);
        r.add("spring.datasource.username", pg::getUsername);
        r.add("spring.datasource.password", pg::getPassword);
        r.add("spring.jpa.database-platform",
               () -> "org.hibernate.dialect.PostgreSQLDialect");
    }
    // نسخه‌ی دوقلوی این کلاس با
    // new OracleContainer("gvenzl/oracle-free:23-slim-faststart")
    // همان سناریوها را روی Oracle می‌آزماید
}
migration را از روز اول دو-گویشی نگه‌دار

در Flyway پوشه‌های db/migration/{vendor} جدا نگه‌دار (flyway.locations را با placeholder بساز) یا در Liquibase از dbms="oracle"/dbms="postgresql" روی هر changeSet استفاده کن. تلاش برای نوشتن یک اسکریپتِ واحد که هر دو را راضی کند معمولاً به کمترین مخرج مشترکِ ضعیف ختم می‌شود. و یادت باشد در Oracle به‌خاطر commit ضمنیِ DDL، هر اسکریپت باید قابلِ ازسرگیری باشد.

چطور یک اپ Spring را طوری طراحی می‌کنی که هم Oracle و هم PostgreSQL را پشتیبانی کند؟

لایه‌لایه: (۱) برای اکثریتِ دسترسی‌ها روی JPA/Hibernate تکیه می‌کنم که با Dialect درست، صفحه‌بندی و تولید کلید و نگاشت نوع را قابل‌حمل می‌کند؛ (۲) SQLِ صریحِ پیچیده را با jOOQ یا با ماندن در استانداردِ ANSI (window، COALESCE/CASE، FETCH FIRST، MERGE، recursive CTE) می‌نویسم و از افزونه‌های اختصاصی (DECODE، CONNECT BY، ON CONFLICT، DISTINCT ON، FILTER، JSON عملگری) پرهیز می‌کنم؛ (۳) migrationها را با Flyway/Liquibase و در صورت لزوم per-vendor مدیریت می‌کنم و ddl-auto=validate می‌گذارم؛ (۴) خطاها را با استثناهای Spring مدیریت می‌کنم نه با کد خطای گویش؛ (۵) در CI با Testcontainers روی هر دو دیتابیسِ واقعی تست می‌گیرم تا تفاوت‌های رفتاری (رشته‌ی خالی، DATE، MERGE، تقسیم صحیح) زود پیدا شوند. جاهایی که ناگزیر گویش‌محورم (LISTAGG/STRING_AGG، keysetِ تاپلی) را پشت یک انتزاع یا کوئریِ per-dialect جدا می‌کنم.

چرا مهاجرت از Oracle به PostgreSQL یک موجِ صنعتی شده و بزرگ‌ترین ریسک‌های فنی‌اش چیست؟

محرک اصلی هزینه است: لایسنس و پشتیبانیِ Oracle گران است و PostgreSQLِ بالغ جایگزین جذابی شده. ریسک‌های فنی: (۱) کدِ PL/SQL و package/trigger که باید به PL/pgSQL بازنویسی شود — بزرگ‌ترین کارِ دستی، به‌خصوص packageها و تراکنش‌های خودمختار که معادل ندارند؛ (۲) تفاوت‌های رفتاریِ ظریف مثل ''=NULL، DATEِ زمان‌دار و تقسیمِ صحیحِ PostgreSQL که باگ‌های خاموش می‌سازند؛ (۳) افزونه‌های اختصاصی (DECODE، CONNECT BY، ROWNUM، MINUS، hintها) که معادلِ مستقیم ندارند؛ (۴) تفاوتِ بهینه‌ساز و planها که کارایی را عوض می‌کند — و این‌که در PostgreSQL hint نداری تا موقتاً درستش کنی؛ (۵) تغییرِ مدلِ عملیات: از undo و ORA-01555 به VACUUM و bloat، و از DDLِ commit-کننده به DDL تراکنشی. ابزارهایی مثل ora2pg کمک می‌کنند ولی هرگز صددرصد خودکار نیستند. کلید موفقیت: تستِ رفتاریِ گسترده و مهاجرتِ تدریجی، نه big-bang.

دو تراکنش هم‌زمان upsert می‌زنند؛ در Oracle چه ریسکی هست و چطور امنش می‌کنی؟

با MERGE، اگر دو تراکنش هم‌زمان برای کلیدِ ناموجود اجرا شوند، هر دو در لحظه‌ی خواندن آن را «موجود نیست» می‌بینند و شاخه‌ی WHEN NOT MATCHED THEN INSERT را می‌روند؛ اولی commit می‌کند و دومی با ORA-00001 می‌شکند — به‌شرط داشتنِ قید یکتا، که حتماً باید باشد وگرنه دو ردیفِ تکراری می‌ماند. امن‌سازی: (۱) UNIQUE روی کلیدِ merge تا بدترین حالت «خطا» باشد نه «داده‌ی خراب»؛ (۲) خطای برخورد را در اپ بگیر و retry کن (بار دوم شاخه‌ی MATCHED می‌رود)؛ (۳) یا پیش از merge با SELECT … FOR UPDATE قفلِ ردیف بگیر. در PostgreSQL راهِ تمیزتر INSERT … ON CONFLICT DO UPDATE است که ذاتاً اتمیک این رقابت را حل می‌کند و نیاز به retry ندارد.

فرق NVL و COALESCE فقط تعداد آرگومان است؟

نه، دو تفاوت مهم‌تر هست. اول، COALESCE استانداردِ ANSI است و روی هر دو کار می‌کند، در حالی‌که NVL اختصاصیِ Oracle است. دوم و ظریف‌تر: COALESCE short-circuit است و به‌محض رسیدن به اولین آرگومانِ غیر-NULL بقیه را ارزیابی نمی‌کند، اما NVL هر دو آرگومان را همیشه ارزیابی می‌کند — پس NVL(x, slow_func()) تابعِ گران را حتی وقتی x غیر-NULL است اجرا می‌کند. سوم، NVL دو آرگومان می‌گیرد ولی COALESCE هر تعداد. نکته‌ی تکمیلی: NVL نوعِ آرگومان دوم را به نوعِ اولی تبدیل می‌کند و می‌تواند خطای تبدیل بدهد. جمع‌بندی: COALESCE هم قابل‌حمل‌تر است هم بالقوه کاراتر.

چرا برای درج انبوه GenerationType.IDENTITY بد است و چه جایگزینی؟

IDENTITY به Hibernate می‌گوید کلید را ستونِ auto-incrementِ دیتابیس تولید می‌کند و مقدارش فقط بعد از اجرای INSERT معلوم می‌شود. چون Hibernate برای هر entity باید بلافاصله id را داشته باشد، نمی‌تواند INSERTها را batch کند و مجبور است تک‌تک بفرستد؛ یعنی JDBC batching عملاً غیرفعال و throughput افت می‌کند. جایگزین: GenerationType.SEQUENCE با @SequenceGenerator(allocationSize = 50) تا Hibernate بلوکی از idها را یک‌بار بگیرد، در حافظه تخصیص دهد و INSERTها را دسته‌ای بفرستد. روی Oracle identity هم زیرِ پرده sequence است پس SEQUENCE طبیعی است؛ روی PostgreSQL افزودن reWriteBatchedInserts=true سود را دوچندان می‌کند.

یک ستون تاریخ در Oracle داری و می‌خواهی همه‌ی رکوردهای یک روز خاص را بگیری. چرا `= DATE '...'` اشتباه است؟

چون در Oracle نوع DATE همیشه شاملِ ساعت/دقیقه/ثانیه است، پس WHERE order_date = DATE '2026-07-23' فقط رکوردهای دقیقاً 00:00:00 را می‌گیرد و بقیه‌ی همان روز را از دست می‌دهد. راهِ درست و ایندکس‌دوست: بازه‌ی نیم‌باز >= DATE '2026-07-23' AND < DATE '2026-07-24'. راهِ TRUNC(order_date) = … هم درست است ولی ایندکسِ معمولی را بی‌اثر می‌کند مگر function-based index روی TRUNC(order_date) بسازی. در PostgreSQL اگر ستون dateِ خالص باشد = DATE '…' کار می‌کند، ولی اگر timestamptz باشد باز همان بازه‌ی نیم‌باز لازم است (و date_trunc('day', col) همان مشکل ایندکس را دارد). درسِ کلی: برای فیلترِ روزانه روی ستون‌های زمان‌دار، همیشه بازه‌ی نیم‌باز.

چرا در پروداکشن نباید روی پیوسته و بدون‌شکاف بودنِ idهای sequence حساب کرد؟

چون sequenceها برای یکتایی و کارایی طراحی شده‌اند، نه پیوستگی. با CACHE n هر instance بلوکی می‌گیرد و اگر دیتابیس ری‌استارت شود یا نودی بمیرد، باقیِ بلوک برای همیشه گم می‌شود؛ تراکنش‌های rollback‌شده هم شماره‌ی مصرف‌شده را برنمی‌گردانند. پیش‌فرض‌ها فرق دارند: Oracle CACHE 20 و PostgreSQL CACHE 1، پس بعد از مهاجرت به Oracle ناگهان شکاف‌ها بیشتر دیده می‌شوند. در RAC چند-نودی، بدون ORDER حتی ترتیبِ صعودیِ سراسری هم تضمین نیست. اگر به شماره‌ی قانوناً پیوسته نیاز داری (شماره‌ی فاکتور)، آن را جدا با یک جدولِ counter و قفل مدیریت کن — کندتر است ولی پیوستگی می‌دهد. برای کلیدِ اصلیِ فنی، شکاف کاملاً بی‌اهمیت است.

تفاوت `TIMESTAMP` و `TIMESTAMPTZ` در PostgreSQL و معادل Oracle چیست؟

TIMESTAMP (without time zone) یک تاریخ-زمانِ بدونِ آگاهی از منطقه است و همان اعداد را ذخیره و برمی‌گرداند. TIMESTAMPTZ مقدار را در UTC ذخیره می‌کند و هنگام خواندن به منطقه‌ی زمانیِ session تبدیل می‌کند — یعنی یک لحظه‌ی مطلق را نمایندگی می‌کند؛ برای رویدادهای واقعی (زمان ثبت سفارش، لاگ) همیشه همین درست است. معادل Oracle، TIMESTAMP WITH TIME ZONE است، و TIMESTAMP WITH LOCAL TIME ZONE که هنگام ذخیره به منطقه‌ی دیتابیس نرمال می‌کند و هنگام خواندن به منطقه‌ی session برمی‌گرداند، نزدیک‌ترین چیز به رفتار timestamptz است. در Java به Instant یا OffsetDateTime نگاشت کن نه LocalDateTime. قاعده‌ی طلایی: ذخیره در UTC، نمایش با منطقه‌ی کاربر.

چطور «آخرین رکورد هر گروه» را قابل‌حمل بین Oracle و PostgreSQL می‌نویسی؟

با یک window function استاندارد که در هر دو یکسان کار می‌کند:

SELECT * FROM (
  SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) rn
  FROM events t
) x WHERE rn = 1;

ROW_NUMBER() در هر گروهِ user_id ردیف‌ها را بر اساس ts نزولی شماره می‌زند و ما ردیفِ ۱ (جدیدترین) را نگه می‌داریم. تنها تفاوت این است که PostgreSQL برای ساب‌کوئریِ داخل FROM نام مستعار می‌خواهد. در PostgreSQL می‌شود همین را با DISTINCT ON (user_id) … ORDER BY user_id, ts DESC کوتاه‌تر نوشت که خواناتر و گاهی سریع‌تر است ولی اختصاصی است. اگر چند ردیف با ts برابر باشند و همه را بخواهم، RANK() می‌گذارم.

چرا تست روی H2 با حالت سازگاری خطرناک است؟

چون H2 فقط نحو را تقلید می‌کند نه رفتار و موتور را. چیزهایی که بازتولید نمی‌کند: تله‌ی رشته‌ی خالیِ Oracle، جزئیاتِ MERGE و ON CONFLICT، سطوح واقعیِ ایزولاسیون و رفتار قفل، commit ضمنیِ DDL در Oracle، planِ بهینه‌ساز و کاراییِ ایندکس، رفتار دقیقِ JSON و منطقه‌ی زمانی. نتیجه: تست سبز می‌شود ولی همان کد در پروداکشن می‌شکند — بدترین نوعِ اطمینانِ کاذب. راهکار سنیور: تست‌های یکپارچگی را با Testcontainers روی همان نسخه‌ی واقعی (postgres:17 و gvenzl/oracle-free:23-slim-faststart) اجرا کن. کندتر است ولی باگ‌های گویش را در CI می‌گیرد، نه ساعت سه بامدادِ پروداکشن.

جمع‌بندی

کپسول نهایی
  • مدل ذهنی: SQL یک زبان با دو گویش است. هسته‌ی استاندارد (SELECT/JOIN/window/CASE/COALESCE/MERGE/FETCH FIRST/recursive CTE) را قابل‌حمل بنویس؛ افزونه‌های اختصاصی را آگاهانه و فقط وقتی به یک دیتابیس قفل شده‌ای.
  • تله‌های خاموش: در Oracle '' همان NULL است و DATE شاملِ زمان؛ در PostgreSQL 1/2 صفر است و now() + 1 خطا.
  • صفحه‌بندی: OFFSET … FETCH FIRST استاندارد و قابل‌حمل؛ ROWNUM قدیمی و با ORDER BY خطرناک؛ برای عمق keyset — ولی Oracle مقایسه‌ی تاپلیِ (a,b) < (x,y) را ندارد.
  • کلید: GENERATED AS IDENTITY مدرن و مشترک؛ برای درج انبوه SEQUENCE با allocationSize؛ پیش‌فرضِ CACHE در Oracle ۲۰ و در PostgreSQL ۱ است و روی پیوستگیِ id هرگز حساب نکن.
  • Upsert: ON CONFLICT اتمیک و امنِ رقابت (فقط PostgreSQL)؛ MERGE قابل‌حمل (PG15+/Oracle) ولی نیازمندِ UNIQUE + retry + منبعِ یکتا (ORA-30926). RETURNING در MERGE از PG17 (با merge_action()) و Oracle 23ai (با OLD/NEW).
  • موتور: هر دو MVCC دارند ولی Oracle نسخه‌ها را در undo نگه می‌دارد (ORA-01555) و PostgreSQL در خودِ جدول (bloat و VACUUM). Oracle سطح REPEATABLE READ ندارد و DDL در آن commit ضمنی می‌کند.
  • عملیات: خطاها را با استثناهای Spring بگیر نه با ORA-؛ planِ واقعی را با EXPLAIN (ANALYZE, BUFFERS) در برابر DBMS_XPLAN.DISPLAY_CURSOR بخوان؛ hint فقط در Oracle هست و AWR لایسنس می‌خواهد.
  • معماری قابلیت حمل: Hibernate dialect + jOOQ + Flyway/Liquibase + Testcontainers روی دیتابیسِ واقعی (نه H2). سنیور کسی است که این تفاوت‌ها را نه حفظ، بلکه پیش‌بینی می‌کند — و پیش از آن‌که در ساعت سه بامداد بترکند، در طراحی و تست خنثی‌شان می‌کند.

Picture two people who both speak "English": one British, one American. Ninety percent of the words are identical, you understand every sentence, but now and then one says lift and the other elevator; one writes colour, the other color. If you're writing formal copy and you don't know these differences, you either embarrass yourself or your text only works for one audience.

Oracle and PostgreSQL are exactly that: two dialects of one language called SQL. Both follow the ANSI/ISO SQL standard, so SELECT, JOIN, GROUP BY and transactions are nearly identical. But each has dozens of "local words" that, if you don't know them, make your code run on one database and blow up with a cryptic error on the other. This chapter is the complete map of those differences — the reasoning behind each decision, real Java/Spring code, and exactly where it bites you in production.

Every snippet here has two tabs: PostgreSQL and Oracle. Click the one you use, but get into the habit of reading both — that's where you see the dialect difference instead of memorizing it.

Chapter roadmap
  • Mental model: the SQL standard vs vendor extensions, and why "writing portably" is an architectural decision.
  • Foundations: DUAL, quoting and identifier length, data-type mapping.
  • Silent traps: Oracle's empty string, the time-bearing DATE, PostgreSQL's integer division.
  • NULL, strings, dates: NVL/DECODE vs COALESCE/CASE, regex, INTERVAL.
  • Sets and pagination: MINUS/EXCEPT, FETCH FIRST, ROWNUM, keyset.
  • Keys, upsert and RETURNING: IDENTITY, SEQUENCE, ON CONFLICT vs MERGE.
  • Advanced: window functions, LISTAGG/STRING_AGG, CONNECT BY vs recursive CTE, JSON, temp tables.
  • The engine: MVCC and undo, isolation, locking, transactional DDL, ORA-01555 vs bloat.
  • Operations: error codes and Spring, plans and hints, PL/SQL vs PL/pgSQL, bulk loading, portability strategy.

Mental model: standard vs dialect

SQL has three layers:

  1. The standard core (ANSI SQL): SELECT/INSERT/UPDATE/DELETE, JOIN, GROUP BY, window functions, CASE, COALESCE. Write these anywhere with confidence.
  2. Standard parts each vendor implements slightly differently: pagination, key generation, date functions. The standard has one official way, but each has its own "local way" too, and the defaults differ.
  3. Fully proprietary extensions: DECODE, CONNECT BY and hints are Oracle's; DISTINCT ON, FILTER and native arrays are PostgreSQL's.
Shared toolbox, different wrenches

Both databases share one toolbox (the SQL standard), but each workshop has also built a few custom wrenches. If you only use the shared tools, your car can be serviced in either workshop. The moment you pick up a custom wrench, your work gets faster but you've tied yourself to that shop. Being senior means knowing when speed is worth it and when the freedom to move is.

Senior judgment: single-database or multi?

If your team will live on Oracle forever, use Oracle's extensions — you paid for the database, use its power. But if you sell your product to customers where one runs Oracle and another PostgreSQL, or a migration is plausible (escaping license cost, the big wave of recent years), then from day one write portable SQL behind an abstraction (Hibernate/jOOQ). Rewriting hundreds of proprietary queries later becomes a painful six-month project.

Diagram: where the dialect layer sits in an app. | جایگاه لایه‌ی دیالکت در معماری اپ.

flowchart LR
  App[Spring Service] --> Repo[Spring Data / JPA]
  Repo --> HB[Hibernate Dialect]
  HB -->|PostgreSQLDialect| PG[(PostgreSQL 16/17)]
  HB -->|OracleDialect| ORA[(Oracle 19c/23ai)]
  App -. native query .-> Repo
  Migr[Flyway / Liquibase] --> PG
  Migr --> ORA

Internalize this right away: by choosing the right Dialect, Hibernate solves a large chunk of these differences for you automatically (pagination, key generation, type mapping). The pain starts where you write native queries or migration scripts by hand — there, you own the dialect.

Foundations: DUAL and SELECT without FROM

In Oracle, SELECT always needed a FROM; to evaluate an expression without a table there is a magic single-row table called DUAL. PostgreSQL never made FROM mandatory:

SELECT now();
SELECT 1 + 1;
Good news in Oracle 23ai

From 23ai onward FROM is optional and you can write SELECT SYSDATE; just like PostgreSQL — but DUAL still works and millions of lines of legacy code ride on it. As long as you're on 19c, get used to writing FROM DUAL in Oracle and never write it in PostgreSQL. The most portable move: don't send these expressions to the database at all, compute them in Java. DUAL claims one more victim: your connection-pool health-check query must be SELECT 1 FROM DUAL on Oracle and SELECT 1 on PostgreSQL.

Case-sensitivity and quoting — the source of 3 a.m. bugs

When you write an identifier (table/column name) without double quotes, Oracle folds it to UPPERCASE (EmployeesEMPLOYEES) and PostgreSQL folds it to lowercase (Employeesemployees). Both look "case-insensitive," but they fold in opposite directions. Disaster strikes when someone creates the table with double quotes:

CREATE TABLE "Employees" (id int);   -- the name is exactly Employees

SELECT * FROM Employees;    -- error: relation "employees" does not exist
SELECT * FROM "Employees";  -- correct
The quoting trap that floors teams

Never create tables and columns with double quotes and mixed case. One bad ORM or a GUI script that quotes everything is enough, and from then on every hand-written query must spell exactly the same "CamelCase" with quotes. In Oracle this means a table you can see in Enterprise Manager but SELECT * FROM employees can't find. Golden rule: keep names snake_case and unquoted. Then both databases stay happy and code moves between them.

Identifier length ceiling: 128 vs 63

Oracle accepts identifiers up to 128 bytes since 12.2 (before that only 30!); PostgreSQL allows 63 bytes (NAMEDATALEN-1). The danger is in the Oracle→PostgreSQL direction: a long name is silently truncated in PostgreSQL, and if two names share the first 63 bytes you get a collision. Hibernate and Liquibase also generate very long automatic index and FK names — on PostgreSQL, name them explicitly and keep them short. And remember PostgreSQL's ceiling is bytes, not characters.

Hibernate and naming strategy

Hibernate's PhysicalNamingStrategy converts entity names to snake_case. Keep this standard on so neither Oracle nor PostgreSQL is ever forced to quote. If you must work with an existing quoted schema, put the quotes explicitly in @Table(name = "\"Employees\"") — but treat that as the exception, not the rule.

Data-type mapping

Keep this table in your pocket; you'll come back to it on every migration and every DDL:

Concept Oracle PostgreSQL Note
Variable string VARCHAR2(n) VARCHAR(n) or TEXT Oracle caps at 4000 bytes (32767 with MAX_STRING_SIZE=EXTENDED) and its default unit is bytes; write VARCHAR2(100 CHAR). PostgreSQL is always characters, up to 1GB.
Large string CLOB TEXT TEXT has no special overhead and is indexable; CLOB is not.
Exact number NUMBER(p,s) NUMERIC(p,s) NUMBER with no args = high precision.
Integer NUMBER(10) INTEGER/BIGINT Oracle has no native integer type.
Machine float BINARY_DOUBLE DOUBLE PRECISION Never float for money.
Date only DATE (includes time!) DATE (date only) Big trap below.
Date+time with zone TIMESTAMP WITH TIME ZONE TIMESTAMPTZ Use this for real-world events.
Interval INTERVAL DAY TO SECOND INTERVAL PostgreSQL has one flexible type, Oracle two separate ones.
Boolean BOOLEAN (23ai+ only) BOOLEAN Before 23ai there was none in SQL.
Binary BLOB/RAW BYTEA
JSON JSON (21c+) or CLOB with IS JSON JSONB/JSON JSONB is best.
UUID RAW(16) + SYS_GUID() uuid + gen_random_uuid() PostgreSQL has a native type.
Physical row address ROWID ctid Never persist either in your app.
Auto-increment key GENERATED … AS IDENTITY GENERATED … AS IDENTITY Both have the standard.

The same table, in both dialects:

CREATE TABLE customer (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email        varchar(320)  NOT NULL UNIQUE,
  full_name    text          NOT NULL,
  credit_limit numeric(12,2) NOT NULL DEFAULT 0,
  is_active    boolean       NOT NULL DEFAULT true,
  attributes   jsonb,
  created_at   timestamptz   NOT NULL DEFAULT now()
);
The DATE trap in Oracle

In Oracle, DATE always includes hour, minute and second — not just the date. So WHERE order_date = DATE '2026-07-23' only returns rows recorded at exactly midnight and loses the rest. The right way: >= DATE '2026-07-23' AND < DATE '2026-07-24', or TRUNC(order_date) = ... which kills the index unless you build a function-based index. In PostgreSQL, DATE really is date-only and you use timestamptz for time. This mismatch is one of the most frequent migration bugs.

Always timestamptz for time (and UTC)

In PostgreSQL prefer timestamptz over unzoned timestamp and store everything in UTC; apply the time zone at the presentation layer. Oracle's equivalent is TIMESTAMP WITH TIME ZONE. In Java keep Instant/OffsetDateTime instead of LocalDateTime. This single decision permanently closes the "why is the 3 a.m. report off by a day" debate.

The second under-discussed trap is numeric division:

SELECT 1 / 2;              -- 0  (integer division!)
SELECT 1.0 / 2;            -- 0.5
SELECT count(*)::numeric / total FROM stat;   -- the correct percentage
`1/2` is zero in PostgreSQL

In Oracle every number is a NUMBER, so 1/2 is 0.5. In PostgreSQL, if both operands are integer the division is integer division and 1/2 is zero — with no error at all. A query that produced a correct percentage on Oracle silently returns zero on PostgreSQL: the worst kind of bug. Cure: cast one operand (count(*)::numeric / total). Division by zero raises in both (ORA-01476 vs SQLSTATE 22012).

The big trap: an empty string in Oracle is NULL

If you remember only one thing from this chapter, make it this:

SELECT '' IS NULL;        -- false
SELECT length('');        -- 0
SELECT '' = '';           -- true

Oracle treats the empty string '' as identical to NULL. This is historical behavior and Oracle itself has said it may change, so rely neither on "'' stays equal to NULL" nor on "'' will one day differ." In PostgreSQL and standard SQL, the empty string is a perfectly valid value that is distinct from NULL.

When the empty string blows up your Java code

Real scenario: a form submits an empty middleName and the app writes "" into a NOT NULL column. On PostgreSQL it saves fine; the same code on Oracle blows up with ORA-01400: cannot insert NULL, because Oracle saw that "" as NULL. The team spends hours chasing "where did the NULL come from?" while the code never sent a NULL. Cure: decide at the input boundary — either normalize empties to null and make the column nullable, or set a meaningful default.

Why is the length of an empty string NULL in Oracle, and what risk does that create?

Because Oracle historically treats '' as equivalent to NULL; so LENGTH('') returns NULL, not 0, and '' IS NULL is TRUE. The risks: (1) inserting "" into a NOT NULL column fails with ORA-01400; (2) col = '' is never TRUE (any comparison with NULL is UNKNOWN) and you must write col IS NULL; (3) code moving between the engines behaves differently — for example a UNIQUE column accepts many "empty" rows on Oracle (they're all NULL) but rejects them on PostgreSQL. Senior fix: normalize input at the app layer and use IS NULL/COALESCE instead of = '' in queries.

Booleans: from NUMBER(1) to a native type

Before 23ai, Oracle had no boolean type in SQL (only in PL/SQL); the common pattern was a NUMBER(1) column with a check. PostgreSQL has had native BOOLEAN from the start:

CREATE TABLE account (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  is_active boolean NOT NULL DEFAULT false
);
SELECT * FROM account WHERE is_active;      -- the column itself is a boolean expression
Boolean portability with Hibernate

If you're on 19c and want your Java code to see a real boolean, Hibernate maps a Java boolean to NUMBER(1)/0,1 on Oracle and to native boolean on PostgreSQL; your entity stays private boolean active; and the dialect layer hides the difference. Only raw native queries need to mind the 1/0. Another subtle difference: in PostgreSQL you can write WHERE is_active, but in Oracle before 23ai there is no boolean expression in SQL at all and you must always write a comparison.

NULL and conditionals: NVL, NVL2, DECODE vs COALESCE and CASE

Both have standard COALESCE and CASE; Oracle also has several popular extensions:

Task Oracle (proprietary) Standard (both)
NULL fallback NVL(a, b) COALESCE(a, b, …)
if not NULL x else y NVL2(a, x, y) CASE WHEN a IS NOT NULL THEN x ELSE y END
translate values DECODE(v, 1,'A', 2,'B', 'other') CASE v WHEN 1 THEN 'A' … ELSE 'other' END
null if equal NULLIF(a, b) NULLIF(a, b) (both)
-- the standard form: works exactly the same on Oracle
SELECT COALESCE(phone, mobile, 'N/A') AS contact,
       CASE status WHEN 'A' THEN 'Active'
                   WHEN 'I' THEN 'Inactive'
                   ELSE 'Unknown' END AS status_label
FROM   customer;
Always write COALESCE and CASE

NVL takes only two arguments; COALESCE takes any number and returns the first non-NULL. More importantly, COALESCE is lazy — it stops evaluating once it finds the first non-NULL, whereas NVL evaluates both arguments, so NVL(x, expensive_function()) runs the expensive function even when x is non-NULL. For portability and performance, make COALESCE/CASE your default and keep DECODE only in legacy code.

DECODE and NULL comparison

DECODE treats NULL as equal to NULL (DECODE(x, NULL, 'was null', …) works), but CASE x WHEN NULL is never TRUE because x = NULL is always UNKNOWN. If you're translating old Oracle code to CASE, handle the NULL case with CASE WHEN x IS NULL THEN … or the behavior changes and you introduce a silent bug.

NULL ordering in ORDER BY — here the two agree

A common myth: that the default NULL ordering differs between the engines. It doesn't — both put NULLs last for ASC (NULLS LAST) and first for DESC, and both accept explicit NULLS FIRST/NULLS LAST. What differs is MySQL and SQL Server, which treat NULL as the smallest value.

Strings: concatenation, substring, search

Concatenation with || works in both and is the most portable way; substring and search differ:

SELECT first_name || ' ' || last_name AS full_name FROM person;
SELECT SUBSTRING('PostgreSQL' FROM 1 FOR 4);   -- 'Post' (standard)
SELECT substr('PostgreSQL', 1, 4);             -- 'Post'
SELECT POSITION('@' IN 'a@b.com');             -- 2 (standard)
SELECT strpos('a@b.com', '@');                 -- 2
INSTR is more powerful, but not portable

Oracle's INSTR also takes a start position and the nth occurrence (INSTR(str, sub, start, occurrence)); standard POSITION has neither, and PostgreSQL has no INSTR, so INSTR(x, y, 1, 2) must be rebuilt with regex. CONCAT is a trap too: Oracle takes only two arguments while PostgreSQL is variadic — write ||. And one difference that really changes results: because of the empty-string trap, NULL || 'x' is 'x' in Oracle but NULL in PostgreSQL.

Both engines have regex, with completely different syntax:

SELECT * FROM person WHERE email ~* '^[a-z0-9._%+-]+@example\.com$';
SELECT regexp_replace('a  b   c', '\s+', ' ', 'g');   -- 'a b c'

-- case-insensitive search via a functional index (portable)
CREATE INDEX idx_person_email_lower ON person (lower(email));
SELECT * FROM person WHERE lower(email) = lower(:input);
Collation: two native ways and one portable way

Both have native options — Oracle with NLS_COMP=LINGUISTIC + NLS_SORT=BINARY_CI or a column COLLATE BINARY_CI (12.2+), PostgreSQL with the citext extension or a non-deterministic ICU collation — but both are non-portable and change index behavior. The senior route: a functional index on LOWER(col) and LOWER() on both sides of the comparison. One more thing worth knowing: string ordering depends on LC_COLLATE in PostgreSQL and on NLS_SORT in Oracle, so ORDER BY name can differ between environments even with identical data.

Push string processing to the app layer

Complex string logic (parsing emails, formatting names, extracting tokens) is both non-portable in the database and hard to test. If the operation is only for display, do it in Java and keep the database for filtering and aggregation, where being close to the data actually pays. This raises portability and reduces database CPU load — the most expensive and hardest-to-scale resource you have.

Dates and time

What time is "now"?

SELECT now();                 -- timestamptz, the transaction-start instant
SELECT clock_timestamp();     -- real wall clock, this exact moment
SELECT CURRENT_TIMESTAMP;     -- standard, same as now()
SYSDATE vs now(): "now" doesn't mean the same thing

SYSDATE gives the database server time and is fresh on every call. now()/CURRENT_TIMESTAMP in PostgreSQL gives the current transaction's start and stays constant through the transaction — if it runs for ten seconds, now() returns one value ten times, and for a truly current stamp you need clock_timestamp(). Second difference, inside Oracle: SYSDATE uses the server's time zone while CURRENT_TIMESTAMP uses the session's; if app and database live in different zones these two disagree and reports shift.

Formatting and parsing use TO_CHAR/TO_DATE in both and most patterns are shared (YYYY, MM, DD, HH24, MI, SS); for constants, standard literals are the most portable:

SELECT to_char(now(), 'YYYY-MM-DD HH24:MI:SS');
SELECT to_date('2026-07-23', 'YYYY-MM-DD');
SELECT DATE '2026-07-23';                       -- standard literal
SELECT TIMESTAMP '2026-07-23 14:30:00';

Date arithmetic is where the dialects truly part ways:

SELECT now() + interval '1 day';          -- tomorrow
SELECT now() + interval '1 month';        -- the ADD_MONTHS equivalent
SELECT date_trunc('month', now());        -- start of month
SELECT age(now(), created_at) FROM customer;
SELECT (end_at - start_at) FROM job_run;  -- difference = interval
Date differences: a number vs an interval

In Oracle date2 - date1 returns a number of days (0.5 means 12 hours) and SYSDATE + 1 means tomorrow. In PostgreSQL the difference of two timestamps is an interval and now() + 1 is an outright error. So WHERE created_at > SYSDATE - 7 must become WHERE created_at > now() - interval '7 days' — one of the most frequent migration failures. EXTRACT is standard in both, but on Oracle's DATE type only YEAR/MONTH/DAY work; for HOUR you must first CAST(d AS TIMESTAMP).

Date formatting is the presentation layer's job

Don't turn a date into a string in the database unless you must. Hand the raw timestamptz/Instant to Java and format with DateTimeFormatter and the user's zone. TO_CHAR in a WHERE breaks portability, nullifies the index on the date column, and locks the time zone at the database level.

Set operators: MINUS vs EXCEPT

UNION, UNION ALL and INTERSECT are identical in both; the difference is "subtraction":

SELECT sku FROM inventory
EXCEPT                      -- ANSI standard
SELECT sku FROM discontinued;

SELECT sku FROM inventory
EXCEPT ALL                  -- without deduplication
SELECT sku FROM discontinued;
MINUS/EXCEPT and versions

Through Oracle 19c only MINUS exists and EXCEPT is a syntax error; from 21c Oracle added EXCEPT, EXCEPT ALL, MINUS ALL and INTERSECT ALL. PostgreSQL has had EXCEPT/EXCEPT ALL forever and has no MINUS. If you must support 19c too, this is one of the few places with no shared syntax — either keep two query versions, or rewrite with NOT EXISTS, which works identically in both and usually gets a better plan anyway.

Pagination: three generations of history

Pagination is where the differences are clearest. Each engine's native way:

-- page three, 20 rows per page
SELECT id, name FROM product
ORDER  BY created_at DESC, id DESC
LIMIT  20 OFFSET 40;

-- PostgreSQL supports the standard form too
SELECT id, name FROM product
ORDER BY created_at DESC, id DESC
OFFSET 40 ROWS FETCH FIRST 20 ROWS ONLY;
One syntax for both

OFFSET n ROWS FETCH FIRST m ROWS ONLY is ANSI standard and works in both (Oracle 12c+ and PostgreSQL). If you're writing a hand-rolled pagination query that must run on both, write this and forget LIMIT. FETCH FIRST … ROWS WITH TIES also exists in both and includes rows tied at the boundary.

Legacy Oracle (11g and earlier) had no FETCH FIRST and forced you into ROWNUM:

-- PostgreSQL has no ROWNUM pseudo-column; row_number() is the equivalent
SELECT * FROM (
  SELECT p.*, row_number() OVER (ORDER BY created_at DESC, id DESC) AS rn
  FROM   product p
) t
WHERE rn > 40 AND rn <= 60;
Why ROWNUM is so hard

ROWNUM is assigned before ORDER BY, not after. So WHERE ROWNUM <= 20 ORDER BY x grabs twenty arbitrary rows and then sorts them — not the top twenty sorted rows. That's why you must trap the ORDER BY in an inner subquery and apply ROWNUM in the outer layer. Anywhere you see code writing WHERE ROWNUM < n ORDER BY … directly and expecting "the first n," that's a bug. From 12c on, retire this pattern with FETCH FIRST.

For deep pagination all three approaches above are disastrous and you must move to keyset pagination (the seek method):

-- row-value comparison: PostgreSQL has it and uses the composite index
SELECT id, name FROM product
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;
Oracle does not support tuple comparison with `<`

An under-discussed and very practical difference: in PostgreSQL WHERE (a, b) < (:a, :b) is legal and the planner executes it well against the composite index on (a, b). In Oracle, a row value constructor is only allowed with =, <> and IN; with < you get ORA-00920: invalid relational operator. So you must write the expanded a < :a OR (a = :a AND b < :b) and make sure the index column order is exactly (created_at, id), or you lose the index. jOOQ handles this difference for you.

A large OFFSET is lethal

With a large OFFSET the database must read and discard every row up to it: OFFSET 1000000 LIMIT 20 means scanning and throwing away a million rows. Keyset with an index on (created_at, id) stays fast regardless of depth, and has a second benefit: if rows are inserted or deleted between pages, keyset never shows a duplicated or skipped row while offset does. "Load more" UIs and infinite scroll should always be keyset.

Diagram: choosing a pagination strategy. | انتخاب استراتژی صفحه‌بندی.

flowchart TD
  Start[Need a page of rows] --> Deep{Deep offset or infinite scroll?}
  Deep -->|Yes| Keyset[Keyset / seek: WHERE key < last_key]
  Deep -->|No, shallow paging| Portable{Need portability?}
  Portable -->|Yes| Ansi[OFFSET ... FETCH FIRST ... ROWS ONLY]
  Portable -->|Oracle 11g legacy| Rownum[ROWNUM triple-nest]
  Portable -->|PostgreSQL only| Limit[LIMIT ... OFFSET]
Spring Data writes pagination for you

Pass a Pageable to the repository and Hibernate, based on its Dialect, generates the right LIMIT/OFFSET or FETCH FIRST; so repo.findAll(PageRequest.of(2, 20, Sort.by("createdAt").descending())) works on both. Only for deep pagination should you consider Slice (which skips the expensive count query) or manual keyset.

Explain the difference between ROWNUM and FETCH FIRST, and why ROWNUM with ORDER BY is dangerous.

ROWNUM is an Oracle pseudo-column that numbers rows at retrieval time, before ORDER BY is applied, so WHERE ROWNUM <= 10 ORDER BY x first grabs 10 arbitrary rows and then sorts them — not the top 10. To work correctly you must put ORDER BY in a subquery and apply ROWNUM outside. By contrast OFFSET … FETCH FIRST … ROWS ONLY is ANSI standard (Oracle 12c+ and PostgreSQL), applied after sorting, and has native OFFSET. PostgreSQL has no ROWNUM at all; the equivalent is row_number(), a window function evaluated after sorting. In new code always FETCH FIRST or keyset; keep ROWNUM only for reading legacy code.

Counters and auto-increment keys

The modern standard in both is GENERATED … AS IDENTITY (PostgreSQL 10+, Oracle 12c+):

CREATE TABLE orders (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  total  numeric(12,2) NOT NULL
);
-- modes: ALWAYS | BY DEFAULT
INSERT INTO orders (total) VALUES (99.90);

GENERATED ALWAYS forbids supplying a value and GENERATED BY DEFAULT allows it; the third mode, BY DEFAULT ON NULL (generate only on an explicit NULL), is Oracle's. Explicit sequences exist in both, but the call syntax differs:

-- nextval/currval are functions and the sequence name is a string
CREATE SEQUENCE seq_order START 1 INCREMENT 1 CACHE 100;
INSERT INTO orders (id, total) VALUES (nextval('seq_order'), 99.90);
SELECT currval('seq_order');
-- PostgreSQL's default CACHE is 1
Sequence CACHE and "holes" in the numbers

CACHE n boosts speed because each instance pre-fetches a block of numbers, but if the database restarts or an instance dies the unused block is lost and a gap appears. Never assume ids are contiguous. The defaults differ too: Oracle uses CACHE 20 and PostgreSQL CACHE 1 — which is exactly why teams migrating to Oracle suddenly report "the numbers are jumping" as a bug. On multi-node RAC, without ORDER even ascending order isn't guaranteed.

After loading data with explicit ids you must advance the counter, or the next insert dies on a duplicate key:

SELECT setval(pg_get_serial_sequence('orders', 'id'),
              (SELECT COALESCE(MAX(id), 0) FROM orders));
SELECT setval('seq_order', 1000, true);   -- for a standalone sequence
@GeneratedValue: pick the right strategy

@GeneratedValue(strategy = GenerationType.IDENTITY) works on PostgreSQL but breaks batch insert, because Hibernate must run each INSERT separately to obtain the id. For high throughput prefer GenerationType.SEQUENCE with @SequenceGenerator(allocationSize = 50) so Hibernate fetches a block of ids and batches the inserts. On Oracle, identity is a sequence under the hood too. Senior rule: for bulk insert, SEQUENCE, with allocationSize aligned to the database CACHE.

What's the difference between GENERATED ALWAYS AS IDENTITY and GENERATED BY DEFAULT, and when do you use each?

ALWAYS doesn't let you supply a value; the database always generates it — the safest mode, preventing accidental manual inserts, and ideal for a pure primary key. BY DEFAULT lets you supply an explicit value, which you need for data migration, but it risks the internal counter falling behind manual values and colliding later; after loading you must advance it with setval() on PostgreSQL and ALTER TABLE … MODIFY (… START WITH LIMIT VALUE) on Oracle. Oracle also has a third mode, BY DEFAULT ON NULL, which PostgreSQL lacks. Recommendation: default to ALWAYS, use BY DEFAULT only for migration tooling.

Upsert: ON CONFLICT vs MERGE

"Upsert" means "update if it exists, insert if it doesn't" — one of the most common operations, with completely different syntax:

-- PostgreSQL: upsert on a unique key (since 9.5)
INSERT INTO inventory (sku, qty, updated_at)
VALUES ('ABC-1', 10, now())
ON CONFLICT (sku)
DO UPDATE SET qty        = inventory.qty + EXCLUDED.qty,
              updated_at = EXCLUDED.updated_at
RETURNING id, qty;
-- DO NOTHING is available if you want to skip the conflict silently

In PostgreSQL EXCLUDED is the row proposed for insertion; in Oracle the source table (USING … s) plays the same role.

MERGE is in both, ON CONFLICT is PostgreSQL-only

MERGE is ANSI standard and PostgreSQL has it since version 15, so you can use MERGE as the portable upsert on both; ON CONFLICT is PostgreSQL-only. Two syntax differences that bite when moving code: (1) Oracle requires parentheses around the ON condition; (2) in Oracle you cannot update a column that appears in the ON condition (ORA-38104), while PostgreSQL has no such restriction.

MERGE and race conditions

MERGE is not race-safe on its own. If two transactions MERGE the same non-existent key concurrently, both may take the NOT MATCHED branch and one then fails with a unique-constraint error (or worst case you get two rows). The right way: a UNIQUE constraint on the key plus retry in the app, or an explicit lock. In PostgreSQL, INSERT … ON CONFLICT resolves this race atomically, which is why it's safer for high-concurrency upserts. Know this difference for interviews — it signals a deep grasp of concurrency.

ORA-30926: the MERGE source must not have duplicate keys

If the USING query produces more than one matching row for a target row, Oracle raises ORA-30926: unable to get a stable set of rows in the source tables because it doesn't know which value to write. Fix: de-duplicate the source first with GROUP BY or ROW_NUMBER() … WHERE rn = 1. PostgreSQL behaves similarly ("MERGE command cannot affect row a second time"). So de-duplicating the source is part of correct design in both, not an option.

From PG17 and Oracle 23ai you can get each row's outcome in the same statement:

-- PostgreSQL 17: MERGE with RETURNING and merge_action()
MERGE INTO inventory t
USING (VALUES ('ABC-1', 10)) AS s(sku, qty)
ON t.sku = s.sku
WHEN MATCHED THEN UPDATE SET qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
RETURNING merge_action() AS action, t.sku, t.qty;
RETURNING in MERGE — the newcomers

PostgreSQL 17 added RETURNING support to MERGE, along with the merge_action() function that tells you whether each row was INSERTed, UPDATEd or DELETEd (and it also brought WHEN NOT MATCHED BY SOURCE). Oracle 23ai brought MERGE … RETURNING with OLD and NEW keywords. Before these versions you needed a separate query to learn each row's outcome. It's a big win for single-round-trip code — but if you must also support 19c, this code won't even compile there.

Diagram: the MERGE decision machine. | ماشین تصمیم MERGE.

flowchart TD
  Row[Source row] --> Match{Key matches target?}
  Match -->|Yes| Upd[WHEN MATCHED -> UPDATE or DELETE]
  Match -->|No| Ins[WHEN NOT MATCHED -> INSERT]
  Upd --> Ret[RETURNING + merge_action]
  Ins --> Ret
  Ret --> Done[Rows affected]
How do you write an upsert in Oracle and PostgreSQL, and which is safer for concurrency?

In PostgreSQL I use INSERT … ON CONFLICT (key) DO UPDATE SET … EXCLUDED…, which is atomic and safely resolves concurrent-insert races. In Oracle I write MERGE INTO … USING … ON … WHEN MATCHED … WHEN NOT MATCHED …. MERGE is standard and works on both since PostgreSQL 15, so I prefer it for portability; but it isn't race-safe on its own — two concurrent transactions can both take the NOT MATCHED branch and one breaks with a unique violation — so I always add a UNIQUE constraint and retry in the app. Two extra points that earn credit: in Oracle I must de-duplicate the source or I get ORA-30926, and on PG17/Oracle 23ai I use RETURNING (with merge_action()) to get the outcome in one round trip.

RETURNING: get the result in the same statement

RETURNING means that after INSERT/UPDATE/DELETE you get back values from the changed rows (like the generated id) without a second query:

-- RETURNING yields a real result set
INSERT INTO orders (total) VALUES (99.90) RETURNING id, created_at;
UPDATE orders SET total = total * 1.1 WHERE id = 5 RETURNING id, total;
DELETE FROM orders WHERE id = 5 RETURNING id;
Oracle 23ai and the improved RETURNING

Oracle 23ai strengthened RETURNING: it now works for UPDATE and MERGE too and exposes OLD and NEW values. One structural difference remains, though: in PostgreSQL the output is a result set you read directly with executeQuery() and it works across many rows; in Oracle the output goes into bind variables and for multiple rows you need RETURNING … BULK COLLECT INTO. In JDBC, both expose the generated id via getGeneratedKeys().

Diagram: fetching a generated key in Spring/JDBC. | گرفتن کلید تولیدشده در Spring/JDBC.

sequenceDiagram
  participant App as Spring Service
  participant JT as JdbcTemplate
  participant DB as DB (PG/Oracle)
  App->>JT: update(INSERT, keyHolder)
  JT->>DB: INSERT ... (generated key column)
  DB-->>JT: generated id
  JT-->>App: keyHolder.getKey()
// Spring: fetching a generated key portably across PostgreSQL and Oracle
public long insertOrder(BigDecimal total) {
    KeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcTemplate.update(connection -> {
        PreparedStatement ps = connection.prepareStatement(
            "INSERT INTO orders (total) VALUES (?)",
            new String[] { "id" });   // key column name — works on both
        ps.setBigDecimal(1, total);
        return ps;
    }, keyHolder);
    return keyHolder.getKey().longValue();
}
Don't confuse `new String[]{"id"}` with `RETURN_GENERATED_KEYS`

On Oracle, if you pass Statement.RETURN_GENERATED_KEYS the driver returns the ROWID, not your id column, and your code breaks with a type-conversion error or a bizarre value. The correct, portable form is the one above: name the key column explicitly. PostgreSQL accepts the same form and its driver translates it into RETURNING id. This is one of those details you only discover when you move code from PostgreSQL to Oracle.

In JDBC/Spring use getGeneratedKeys

If you only want the generated key, skip hand-written dialect-specific RETURNING: with JdbcTemplate pass a KeyHolder and name the key column. For entities, @GeneratedValue does the same behind the scenes. Write explicit RETURNING only when you want multiple columns or computed values and you're locked to one database.

Window (analytic) functions

Great news: window functions are part of the SQL standard and work identically in both:

SELECT product_id, category, sales,
       RANK()     OVER (PARTITION BY category ORDER BY sales DESC) AS rk,
       SUM(sales) OVER (PARTITION BY category)                     AS cat_total,
       LAG(sales) OVER (PARTITION BY category ORDER BY month)      AS prev_month
FROM   product_sales;
Window functions are seniors' hidden weapon

A lot of logic a junior solves with several queries and a Java loop (ranking, "last record per group," running totals, difference from the previous row) collapses into one portable query with a window function. Learning them is the visible difference between mid and senior in an interview.

DISTINCT ON and FILTER are PostgreSQL-only

PostgreSQL has two shortcuts Oracle lacks: DISTINCT ON for "first row per group" and the standard FILTER clause for conditional aggregation. The portable equivalents are ROW_NUMBER() and COUNT(CASE …):

SELECT DISTINCT ON (user_id) * FROM events ORDER BY user_id, ts DESC;

SELECT COUNT(*) AS total,
       COUNT(*) FILTER (WHERE status = 'PAID') AS paid
FROM   orders;

The COUNT(CASE …) form works in both, so write that one if portability matters.

String aggregation: LISTAGG vs STRING_AGG

"Turn multiple rows into one comma-separated string" — a common reporting task:

SELECT department_id,
       STRING_AGG(last_name, ', ' ORDER BY last_name) AS names
FROM   employees
GROUP  BY department_id;
LISTAGG overflow and the 4000-byte limit

LISTAGG blows up with ORA-01489: result of string concatenation is too long once the result exceeds the VARCHAR2 limit (4000 bytes, or 32767 with extended) — which happens a lot on large groups in real reports. Add the ON OVERFLOW TRUNCATE clause (12c R2+) so it truncates instead of erroring. STRING_AGG, aggregating into TEXT, has no such ceiling. The syntax differs too: the ordering lives inside WITHIN GROUP in Oracle and inside the function itself in PostgreSQL.

Turn multiple rows into a comma-separated string — in both databases.

PostgreSQL: STRING_AGG(col, ', ' ORDER BY col). Oracle: LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col) with GROUP BY. The differences I'd mention: (1) LISTAGG has the 4000/32767-byte VARCHAR2 ceiling and gives ORA-01489 on a large group, so I add ON OVERFLOW TRUNCATE; STRING_AGG on TEXT has no such limit. (2) The ORDER BY syntax differs. (3) LISTAGG DISTINCT exists since 19c but older versions need a workaround, while STRING_AGG(DISTINCT …) has always existed. (4) If portability matters, this is one of the places where you either keep two versions or push it to the app layer.

Hierarchical queries: CONNECT BY vs recursive CTE

Org charts, nested categories, bills of materials — anywhere rows reference their own parent. Oracle has a proprietary legacy syntax that exists in no other database:

-- ANSI standard: WITH RECURSIVE (the RECURSIVE keyword is mandatory)
WITH RECURSIVE org AS (
  SELECT id, name, manager_id, 1 AS lvl, name::text AS path
  FROM   employee WHERE manager_id IS NULL          -- the roots
  UNION ALL
  SELECT e.id, e.name, e.manager_id, o.lvl + 1, o.path || '/' || e.name
  FROM   employee e
  JOIN   org o ON e.manager_id = o.id               -- the recursive step
)
SELECT lvl, path, name FROM org ORDER BY path;
Understand CONNECT BY, but don't write new ones

CONNECT BY has real advantages — LEVEL, SYS_CONNECT_BY_PATH, CONNECT_BY_ROOT, CONNECT_BY_ISLEAF, ORDER SIBLINGS BY and NOCYCLE — all of which you must rebuild by hand in a recursive CTE. But Oracle has had the standard form since 11gR2, so the senior rule is: understand legacy CONNECT BY and leave it alone, but write every new hierarchical query as a recursive CTE so it runs on both. First porting trap: the RECURSIVE keyword is mandatory in PostgreSQL and not written in Oracle.

How do you walk an org chart in Oracle and PostgreSQL?

In PostgreSQL there's exactly one way: WITH RECURSIVE, with a base member (the roots) and a recursive member that joins back to the CTE. In Oracle I have two: CONNECT BY PRIOR … START WITH …, which is native, very terse, and hands me LEVEL, CONNECT_BY_ROOT, SYS_CONNECT_BY_PATH and ORDER SIBLINGS BY for free; and, since 11gR2, the same standard WITH. For new portable code I write the recursive CTE and build level and path by hand. Two practical notes: the RECURSIVE keyword is mandatory in PostgreSQL but not in Oracle; and for cyclic data Oracle has NOCYCLE/CONNECT_BY_ISCYCLE while PostgreSQL has the CYCLE … SET … USING … clause since version 14 — without them the query runs forever and takes the server down.

JSON in both

Both take JSON seriously but the models differ: PostgreSQL has the indexable binary jsonb, Oracle has had a native JSON type since 21c:

CREATE TABLE doc (id bigint GENERATED ALWAYS AS IDENTITY, body jsonb);

SELECT body ->> 'name'                AS name,      -- text
       body -> 'address' ->> 'city'   AS city,      -- nested
       body @> '{"active": true}'     AS is_active  -- containment
FROM   doc
WHERE  body @> '{"type": "customer"}';              -- benefits from a GIN index

CREATE INDEX idx_doc_body ON doc USING gin (body);

-- PostgreSQL 17: standard SQL/JSON functions
SELECT JSON_VALUE(body, '$.address.city') FROM doc WHERE JSON_EXISTS(body, '$.type');
SQL/JSON shared, operators differ

The standard functions JSON_VALUE, JSON_QUERY, JSON_TABLE and JSON_EXISTS now exist in both (PostgreSQL since 17, Oracle for a while) and are better for portability. But PostgreSQL's shortcut operators (->, ->>, @>, #>) and Oracle's dot notation are proprietary. Indexing differs too: GIN over the whole document in PostgreSQL versus CREATE SEARCH INDEX … FOR JSON or a functional index on a specific JSON_VALUE in Oracle. If you're on PostgreSQL 16 you don't have JSON_TABLE yet; it arrived in 17.

Use JSON in a relational database with care

JSON is great for genuinely semi-structured, variable data (settings, raw payloads, dynamic attributes), but don't dump everything into one jsonb column. If you regularly WHERE/JOIN/ORDER BY on a field, it should be a real typed column, not inside JSON — otherwise indexes, constraints and the optimizer can't help you. The senior pattern: pull the "hot," queried fields into columns and keep the rest of the "cold," variable data in jsonb. JSON is not a substitute for schema design.

Temp tables: GTT vs TEMP TABLE

Temp tables show up in ETL staging and heavy reporting, and the two engines' mental models are completely different:

-- both the definition and the data are temporary; dropped at session end
CREATE TEMP TABLE stage_order (
  id bigint, total numeric(12,2)
) ON COMMIT DELETE ROWS;   -- or ON COMMIT DROP

INSERT INTO stage_order SELECT id, total FROM orders
WHERE created_at > now() - interval '1 day';
ANALYZE stage_order;       -- so the planner isn't blind
Create the GTT in a migration, not at runtime

The conceptual difference: in Oracle you create a GLOBAL TEMPORARY TABLE once in a migration and it stays in the dictionary forever, with each session seeing only its own data. In PostgreSQL each session issues its own CREATE TEMP TABLE. If you port PostgreSQL code that creates a temp table per transaction to Oracle, you're issuing DDL on every call — meaning an implicit commit, dictionary locking and a severe performance hit. The reverse hurts too: creating hundreds of thousands of temp tables bloats pg_class. In both, gather statistics after filling the temp table or the optimizer decides blind.

Transactions, locking and isolation

Both engines are MVCC ("readers don't block writers"), but the implementations differ fundamentally, and that determines how they fail in production: Oracle keeps the old row version in the UNDO tablespace, and a long reader that reaches overwritten undo gets ORA-01555: snapshot too old; PostgreSQL keeps old versions in the table itself, so if VACUUM falls behind, tables and indexes bloat.

-- levels: READ COMMITTED (default), REPEATABLE READ, SERIALIZABLE (true SSI)
BEGIN ISOLATION LEVEL REPEATABLE READ;
  SELECT SUM(total) FROM orders;
COMMIT;

VACUUM (ANALYZE) orders;
SELECT relname, n_dead_tup FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
Oracle has no REPEATABLE READ level

Write @Transactional(isolation = Isolation.REPEATABLE_READ) in Spring and run it on Oracle and you get an error, because Oracle only implements READ COMMITTED, SERIALIZABLE and READ ONLY; the practical equivalent of REPEATABLE READ is SERIALIZABLE (which is really snapshot isolation and raises ORA-08177 on conflict). PostgreSQL accepts all four names, but READ UNCOMMITTED behaves as READ COMMITTED, REPEATABLE READ is snapshot isolation, and SERIALIZABLE is truly serializable (SSI) and raises 40001 on conflict. Practical consequence: any transaction above READ COMMITTED must be retryable in the app — on both engines.

Explicit locking shares its syntax, with one small but important difference:

-- work queue: each worker takes a batch without contention
SELECT id, payload FROM job_queue
WHERE  status = 'READY'
ORDER  BY created_at
FOR UPDATE SKIP LOCKED
FETCH FIRST 10 ROWS ONLY;

SELECT * FROM orders WHERE id = 1 FOR UPDATE NOWAIT;   -- fail immediately
SET lock_timeout = '5s';   -- PostgreSQL has no "WAIT n"
DDL implicitly commits in Oracle — not in PostgreSQL

One of the deepest differences, with direct impact on migration design. In Oracle every CREATE/ALTER/DROP issues an implicit COMMIT before and after itself, so you cannot roll back a group of structural changes atomically, and a script that breaks halfway leaves half its changes permanent. In PostgreSQL almost all DDL is transactional. Consequence: on Oracle every migration must be idempotent and resumable; on PostgreSQL you can wrap a whole version in one transaction (and Flyway does exactly that). Important PostgreSQL exception: CREATE INDEX CONCURRENTLY cannot run inside a transaction.

BEGIN;
  ALTER TABLE orders ADD COLUMN note text;
  CREATE INDEX idx_orders_note ON orders (note);
ROLLBACK;             -- nothing remains
Both are MVCC — so what's the difference in production?

The difference is where the old version lives. Oracle writes it to UNDO, so the table stays clean, but a multi-hour report can reach overwritten undo and get ORA-01555 snapshot too old — cured by sizing undo, tuning UNDO_RETENTION, or shortening reader transactions. PostgreSQL keeps old versions in the table page itself, so a long reader never errors but it blocks VACUUM: tables and indexes bloat, I/O climbs, and eventually you risk transaction-ID wraparound — cured by well-tuned autovacuum and avoiding long-open transactions. The line that earns credit: "on Oracle a long reader hurts itself; on PostgreSQL it hurts the whole database." Two further differences: Oracle has no REPEATABLE READ level, and its DDL commits implicitly.

Error codes and how Spring translates them

If you want identical behavior on both engines, you must know what a violated constraint looks like on each:

What happened Oracle PostgreSQL (SQLSTATE) Spring exception
Unique violation ORA-00001 23505 DuplicateKeyException
NOT NULL violation ORA-01400 23502 DataIntegrityViolationException
Foreign key violation ORA-02291/ORA-02292 23503 DataIntegrityViolationException
CHECK violation ORA-02290 23514 DataIntegrityViolationException
Deadlock ORA-00060 40P01 DeadlockLoserDataAccessException
Serialization failure ORA-08177 40001 CannotAcquireLockException
Lock not available ORA-00054 55P03 CannotAcquireLockException
Value too large for column ORA-12899 22001 DataIntegrityViolationException
Invalid numeric string ORA-01722 22P02 DataIntegrityViolationException

Name your constraints so you can translate the error into a user-facing message on both:

ALTER TABLE customer ADD CONSTRAINT uq_customer_email UNIQUE (email);
-- message: duplicate key value violates unique constraint "uq_customer_email"
Let Spring translate, not your code

Spring maps both engines' errors into the DataAccessException hierarchy: from SQLSTATE for PostgreSQL and from the error codes listed in sql-error-codes.xml for Oracle. So never branch on ORA-00001 or on message text in your service; write catch (DuplicateKeyException e) and the code works on both. For transient errors (CannotAcquireLockException, DeadlockLoserDataAccessException) use Spring Retry with backoff. If you need the constraint name, remember Oracle returns it UPPERCASE and PostgreSQL lowercase — compare case-insensitively.

A service must handle "duplicate email" cleanly on both Oracle and PostgreSQL. How?

I rely on the database rather than a pre-check SELECT, which is inherently racy: I put a named UNIQUE constraint on the column and just insert. Oracle raises ORA-00001 and PostgreSQL SQLSTATE 23505, and Spring maps both to DuplicateKeyException, so my service catches that one exception and turns it into a domain error — with no dialect-specific code. If the table has several unique constraints I read the constraint name from the exception and compare it case-insensitively, because Oracle returns names in UPPERCASE and PostgreSQL in lowercase. And for transient errors like deadlock (ORA-00060/40P01) or serialization failure (ORA-08177/40001) I add a retry layer, because those aren't bugs — they're a normal part of concurrent work.

Plans, statistics, hints and monitoring

When a query gets slow, the tooling in the two engines is completely different:

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;        -- estimated

-- the real plan with timing and I/O — write this one
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM orders WHERE customer_id = 42;

ANALYZE orders;   -- refresh statistics
Estimate vs reality

EXPLAIN without ANALYZE and EXPLAIN PLAN are both only the optimizer's estimate and can differ from what actually runs — especially on Oracle, where bind peeking and adaptive plans mean the displayed plan isn't necessarily the executed one. What you look for is the same in both: the gap between estimated and actual rows. If the optimizer expected 10 rows and 100,000 came back, the problem is statistics or selectivity, not a missing index.

The biggest philosophical difference is here: Oracle lets you give orders to the optimizer, PostgreSQL doesn't:

-- PostgreSQL has no in-query hints (a deliberate project decision)
SET enable_seqscan = off;
SELECT * FROM orders WHERE customer_id = 42;
RESET enable_seqscan;
-- for real hints you must install the pg_hint_plan extension
Don't make hints a permanent cure — and mind the AWR licence

Two senior warnings. First: a hint works immediately but freezes a plan tied to one data distribution; six months later the same hint makes the query slow, and because it lives in the code nobody revisits it. The real cure is usually fresh statistics, a better index or a rewritten query. And if you move hinted code to PostgreSQL the comments are silently ignored and the query gets a different plan — "it works but it's slow," the worst outcome. Second and more expensive: AWR, ASH and DBA_HIST_* require the Diagnostics Pack licence; running awrrpt.sql on a database without it is an audit risk. Licence-free alternatives: V$SQL, V$SESSION and Statspack. In PostgreSQL everything is free: pg_stat_statements, pg_stat_activity, auto_explain.

-- the most expensive queries (requires the pg_stat_statements extension)
SELECT calls, mean_exec_time, rows, query
FROM   pg_stat_statements ORDER BY total_exec_time DESC
FETCH FIRST 10 ROWS ONLY;

SELECT pid, state, wait_event_type, wait_event, query
FROM   pg_stat_activity WHERE state <> 'idle';
You have a slow query on both databases. What's your debugging path, and what differs?

The reasoning is identical; the tools differ. First I get the real plan: EXPLAIN (ANALYZE, BUFFERS) on PostgreSQL, GATHER_PLAN_STATISTICS plus DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST') on Oracle. Then I look for the gap between estimate and reality and refresh statistics if it's large (ANALYZE vs DBMS_STATS.GATHER_TABLE_STATS). Then I look at access patterns: a full scan on a big table, a nested loop with a huge iteration count, or a sort spilling to disk (work_mem vs PGA_AGGREGATE_TARGET). For system-level patterns I read pg_stat_statements vs V$SQL — remembering that AWR/ASH need the Diagnostics Pack. Final philosophical difference: on Oracle I can ultimately pin a plan with a hint or a SQL Plan Baseline; PostgreSQL has nothing like that, so I must fix the query, the index or the planner settings — which is usually the healthier cure anyway.

PL/SQL vs PL/pgSQL

Both have a procedural language and they look similar (both are Ada-inspired), but the details differ:

CREATE OR REPLACE FUNCTION order_total(p_id bigint)
RETURNS numeric
LANGUAGE plpgsql
AS $$
DECLARE
  v_total numeric := 0;
BEGIN
  SELECT COALESCE(SUM(qty * price), 0) INTO v_total
  FROM   order_line WHERE order_id = p_id;
  RETURN v_total;
EXCEPTION
  WHEN no_data_found THEN RETURN 0;
END;
$$;

SELECT order_total(42);
What has no equivalent in PostgreSQL

(1) Packages — Oracle's CREATE PACKAGE is a namespace with session-scoped variables; PostgreSQL has none and you emulate it with schemas and separate functions. (2) Autonomous transactionsPRAGMA AUTONOMOUS_TRANSACTION (to log errors even when the main transaction rolls back) doesn't exist; the workaround is dblink or moving logging outside the database. (3) PostgreSQL takes the body as a dollar-quoted string and needs LANGUAGE plpgsql. (4) In Oracle a function with a compile error is stored as INVALID (run SHOW ERRORS), while PostgreSQL doesn't fully check the body until run time. (5) Since PostgreSQL 11 you also have PROCEDURE, which can COMMIT inside itself.

PL/SQL is the biggest migration cost

If your business logic lives in PL/SQL packages, migrating to PostgreSQL is no longer "changing SQL dialect" — it's "rewriting an application." Tools like ora2pg automate much of the translation but never all of it. The architectural lesson for today: keep new business logic in the application layer and use the database for data and integrity; then the day the migration question comes up, the answer is a decision rather than a two-year project.

Bulk loading and JDBC batching

-- the fastest path: COPY (use \copy in psql for a client-side file)
COPY orders (id, customer_id, total)
FROM '/data/orders.csv' WITH (FORMAT csv, HEADER true);

ANALYZE orders;   -- refresh statistics after a bulk load
One parameter that multiplies PostgreSQL bulk-insert throughput

If you use JDBC with addBatch(), the PostgreSQL driver sends each INSERT separately by default; adding reWriteBatchedInserts=true to the JDBC URL makes it rewrite them into a single INSERT … VALUES (…), (…) and throughput typically multiplies. Oracle needs no such switch because its driver sends arrays; instead raise defaultRowPrefetch for large reads. On both, set hibernate.jdbc.batch_size and use GenerationType.SEQUENCE or batching never engages at all. And note that reWriteBatchedInserts changes error semantics: with one multi-row statement, the first error nullifies the whole group.

Portability strategy: write once, run everywhere

The four layers of portability defense
  1. ORM/Hibernate with the right Dialect: automates most CRUD, pagination, key generation and type mapping.
  2. jOOQ for type-safe SQL: generates portable but explicit SQL (it even expands the keyset tuple comparison for Oracle).
  3. Flyway/Liquibase for migrations: versions your DDL; Liquibase can even produce database-agnostic changelogs.
  4. Testcontainers to test on both: run integration tests against real Oracle and real PostgreSQL (not H2) so you catch differences in CI, not in production.
# application.yml — Hibernate auto-detects the dialect,
# but writing it explicitly is clearer
spring:
  jpa:
    hibernate:
      ddl-auto: validate        # never create/update in production
    properties:
      hibernate:
        jdbc:
          batch_size: 50        # for bulk insert with SEQUENCE
        order_inserts: true
---
spring:
  config:
    activate:
      on-profile: postgres
  datasource:
    url: jdbc:postgresql://db-host:5432/app?reWriteBatchedInserts=true
  jpa:
    database-platform: org.hibernate.dialect.PostgreSQLDialect
---
spring:
  config:
    activate:
      on-profile: oracle
  datasource:
    url: jdbc:oracle:thin:@//db-host:1521/ORCLPDB1
  jpa:
    database-platform: org.hibernate.dialect.OracleDialect
H2 "compatibility mode" is not a substitute for the real database

A common mistake: testing on H2 with MODE=Oracle because it's fast. H2 does not reproduce many real behaviors (the empty-string trap, exact MERGE semantics, isolation levels, Oracle's implicit DDL commit, JSON behavior, the query plan); your test goes green but production goes red. With Testcontainers, spin up the same real version in Docker and run integration tests against it. It's slower but catches dialect bugs before production — exactly where those bugs are most expensive.

// Testcontainers: the same test, against both real databases
@SpringBootTest
@Testcontainers
class OrderRepositoryPgTest {

    @Container
    static PostgreSQLContainer<?> pg =
        new PostgreSQLContainer<>("postgres:17-alpine");

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", pg::getJdbcUrl);
        r.add("spring.datasource.username", pg::getUsername);
        r.add("spring.datasource.password", pg::getPassword);
        r.add("spring.jpa.database-platform",
               () -> "org.hibernate.dialect.PostgreSQLDialect");
    }
    // the twin of this class, using
    // new OracleContainer("gvenzl/oracle-free:23-slim-faststart"),
    // runs the same scenarios on Oracle
}
Keep migrations dual-dialect from day one

In Flyway keep separate db/migration/{vendor} folders (build flyway.locations with a placeholder), or in Liquibase use dbms="oracle"/dbms="postgresql" on each changeSet. Trying to write a single script that pleases both usually ends in a weak lowest-common-denominator. And remember that on Oracle, because DDL commits implicitly, every script must be resumable.

How do you design a Spring app to support both Oracle and PostgreSQL?

Layered: (1) for most data access I lean on JPA/Hibernate, which with the right Dialect makes pagination, key generation and type mapping portable; (2) complex explicit SQL I write with jOOQ or by staying inside ANSI standard (window functions, COALESCE/CASE, FETCH FIRST, MERGE, recursive CTEs) and avoiding proprietary extensions (DECODE, CONNECT BY, ON CONFLICT, DISTINCT ON, FILTER, operator-based JSON); (3) I manage migrations with Flyway/Liquibase, separating per-vendor when needed, and set ddl-auto=validate; (4) I handle errors through Spring's exception hierarchy rather than dialect error codes; (5) in CI I run integration tests with Testcontainers against both real databases so behavioral differences (empty string, DATE, MERGE, integer division) surface early. Where I'm unavoidably dialect-specific (LISTAGG/STRING_AGG, tuple keyset) I hide it behind an abstraction or per-dialect queries.

Why has Oracle-to-PostgreSQL migration become an industry wave, and what are its biggest technical risks?

The main driver is cost: Oracle licensing and support are expensive and mature PostgreSQL has become an attractive alternative. The technical risks: (1) PL/SQL code, packages and triggers must be rewritten to PL/pgSQL — the biggest manual effort, especially packages and autonomous transactions, which have no equivalent; (2) subtle behavioral differences like ''=NULL, Oracle's time-bearing DATE and PostgreSQL's integer division, which create silent bugs; (3) proprietary extensions (DECODE, CONNECT BY, ROWNUM, MINUS, hints) with no direct equivalent; (4) optimizer and plan differences that change performance — and PostgreSQL gives you no hints to patch things temporarily; (5) a different operational model: from undo and ORA-01555 to VACUUM and bloat, and from committing DDL to transactional DDL. Tools like ora2pg help but are never fully automatic. The key to success: extensive behavioral testing and incremental migration, not big-bang.

Two transactions upsert concurrently; what's the risk in Oracle and how do you make it safe?

With MERGE, if two transactions run concurrently for a non-existent key, both see it as absent at read time and take the WHEN NOT MATCHED THEN INSERT branch; the first commits and the second breaks with ORA-00001 — provided there's a unique constraint, which there must be, or you end up with two duplicate rows. Making it safe: (1) always have a UNIQUE on the merge key so the worst case is an error, not corrupt data; (2) catch the conflict in the app and retry (the second attempt takes the MATCHED branch); (3) or lock the row with SELECT … FOR UPDATE before the merge. In PostgreSQL the cleaner way is INSERT … ON CONFLICT DO UPDATE, which resolves the race atomically and needs no retry.

Is the difference between NVL and COALESCE just the argument count?

No, there are two more important differences. First, COALESCE is ANSI standard and works on both, whereas NVL is Oracle-specific. Second and subtler: COALESCE short-circuits — as soon as it reaches the first non-NULL argument it stops evaluating — but NVL always evaluates both arguments, so NVL(x, slow_func()) runs the expensive function even when x is non-NULL. Third, NVL takes two arguments while COALESCE takes any number. One more nuance: NVL converts the second argument to the first one's type and can raise a conversion error. Summary: COALESCE is both more portable and potentially more efficient.

Why is GenerationType.IDENTITY bad for bulk insert, and what's the alternative?

IDENTITY tells Hibernate the key comes from the database's auto-increment column and is only known after the INSERT runs. Because Hibernate needs the id immediately for each entity, it cannot batch the INSERTs and must send them one by one — JDBC batching is effectively disabled and throughput collapses. The alternative: GenerationType.SEQUENCE with @SequenceGenerator(allocationSize = 50), so Hibernate fetches a block of ids once, assigns them in memory and sends the INSERTs batched. On Oracle, identity is a sequence under the hood anyway; on PostgreSQL, adding reWriteBatchedInserts=true to the URL doubles down on the benefit.

You have a date column in Oracle and want all records from a specific day. Why is `= DATE '...'` wrong?

Because Oracle's DATE always includes hour/minute/second, so WHERE order_date = DATE '2026-07-23' only matches rows at exactly 00:00:00 and loses the rest of the day. The correct, index-friendly way is the half-open range >= DATE '2026-07-23' AND < DATE '2026-07-24'. TRUNC(order_date) = … is also correct but nullifies an ordinary index unless you build a function-based index on TRUNC(order_date). In PostgreSQL, if the column is a pure date then = DATE '…' works, but with timestamptz you still need the half-open range (and date_trunc('day', col) has the same index problem). General lesson: for daily filters on time-bearing columns, always use a half-open range.

Why can't you rely on sequence ids being contiguous and gap-free in production?

Because sequences are designed for uniqueness and performance, not contiguity. With CACHE n each instance pre-fetches a block, and if the database restarts or a node dies the rest of that block is lost forever; rolled-back transactions also never return the consumed number. The defaults differ: Oracle uses CACHE 20 and PostgreSQL CACHE 1, so gaps become much more visible after migrating to Oracle. On multi-node RAC, without ORDER even global ascending order isn't guaranteed. So if you need a legally contiguous number (an invoice number), manage it separately with a counter table and a lock — slower, but contiguous. For a technical primary key, gaps are completely irrelevant.

What's the difference between `TIMESTAMP` and `TIMESTAMPTZ` in PostgreSQL, and Oracle's equivalent?

TIMESTAMP (without time zone) is a date-time with no zone awareness; it stores and returns the same numbers regardless of server or session zone. TIMESTAMPTZ stores the value in UTC and converts it to the session's zone on read — it represents an absolute instant, which is always what you want for real-world events (order timestamps, logs). Oracle's equivalent is TIMESTAMP WITH TIME ZONE, and TIMESTAMP WITH LOCAL TIME ZONE — which normalizes to the database zone on write and converts to the session zone on read — is the closest thing to timestamptz behavior. In Java map it to Instant or OffsetDateTime, never LocalDateTime. Golden rule: store in UTC, display in the user's zone.

How do you write "the last record per group" portably between Oracle and PostgreSQL?

With a standard window function that works identically in both:

SELECT * FROM (
  SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) rn
  FROM events t
) x WHERE rn = 1;

ROW_NUMBER() numbers the rows within each user_id group by descending ts and we keep row number 1 (the newest). The only difference is that PostgreSQL requires an alias for a subquery in FROM. In PostgreSQL you could write it shorter with DISTINCT ON (user_id) … ORDER BY user_id, ts DESC, which is more readable and sometimes faster but proprietary. If several rows share an equal ts and I want all of them, I use RANK() instead.

Why is testing on H2 with compatibility mode dangerous?

Because H2 only mimics the syntax, not the behavior and engine. What it doesn't reproduce: Oracle's empty-string trap, the exact semantics of MERGE and ON CONFLICT, real isolation levels and locking behavior, Oracle's implicit DDL commit, the optimizer plan and index performance, exact JSON and time-zone behavior. The result: a green test on H2 and the same code breaking in production — the worst kind of false confidence. Senior fix: run integration tests with Testcontainers against the same real versions (postgres:17 and gvenzl/oracle-free:23-slim-faststart). It's slower but it catches dialect bugs in CI, not at 3 a.m. in production.

Wrap-up

Final capsule
  • Mental model: SQL is one language with two dialects. Write the standard core (SELECT/JOIN/window/CASE/COALESCE/MERGE/FETCH FIRST/recursive CTE) portably; use proprietary extensions deliberately and only when locked to one database.
  • Silent traps: in Oracle '' is NULL and DATE carries time; in PostgreSQL 1/2 is zero and now() + 1 is an error.
  • Pagination: OFFSET … FETCH FIRST is standard and portable; ROWNUM is legacy and dangerous with ORDER BY; for depth use keyset — but remember Oracle has no (a,b) < (x,y) tuple comparison.
  • Keys: GENERATED AS IDENTITY is modern and shared; for bulk insert use SEQUENCE with allocationSize; the CACHE default is 20 on Oracle and 1 on PostgreSQL, and never rely on id contiguity.
  • Upsert: ON CONFLICT is atomic and race-safe (PostgreSQL-only); MERGE is portable (PG15+/Oracle) but needs UNIQUE + retry + a de-duplicated source (ORA-30926). RETURNING in MERGE since PG17 (with merge_action()) and Oracle 23ai (with OLD/NEW).
  • The engine: both are MVCC, but Oracle keeps versions in undo (ORA-01555) and PostgreSQL in the table itself (bloat and VACUUM). Oracle has no REPEATABLE READ level and its DDL commits implicitly.
  • Operations: catch errors through Spring's exceptions, not ORA- codes; read the real plan with EXPLAIN (ANALYZE, BUFFERS) vs DBMS_XPLAN.DISPLAY_CURSOR; hints exist only on Oracle and AWR needs a licence.
  • Portability architecture: Hibernate dialect + jOOQ + Flyway/Liquibase + Testcontainers on the real database (not H2). A senior is someone who doesn't memorize these differences but anticipates them — and neutralizes them in design and tests before they blow up at 3 a.m.