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 سه لایه دارد:
- هستهی استاندارد (ANSI SQL):
SELECT/INSERT/UPDATE/DELETE،JOIN،GROUP BY، توابع پنجرهای،CASE،COALESCE. اینها را با خیال راحت همهجا بنویس. - قسمتهای استانداردی که هر vendor کمی متفاوت پیاده کرده: صفحهبندی، تولید کلید، توابع تاریخ. استاندارد یک راه رسمی دارد ولی هر کدام «راه محلیِ» خودشان را هم دارند و پیشفرضها فرق میکند.
- افزونههای کاملاً اختصاصی:
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;SELECT SYSDATE FROM DUAL;
SELECT 1 + 1 FROM DUAL;
-- Oracle 23ai: FROM دیگر اجباری نیست
-- SELECT SYSDATE;از 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 آن را به حروف بزرگ تا میکند (Employees → EMPLOYEES) و PostgreSQL به حروف کوچک (Employees → employees). هر دو «case-insensitive» بهنظر میرسند ولی در جهتهای مخالف. فاجعه وقتی است که کسی جدول را با دابلکوت بسازد:
CREATE TABLE "Employees" (id int); -- نام دقیقاً Employees میشود
SELECT * FROM Employees; -- خطا: relation "employees" does not exist
SELECT * FROM "Employees"; -- درستCREATE TABLE "Employees" (id NUMBER); -- نام دقیقاً Employees میشود، نه EMPLOYEES
SELECT * FROM Employees; -- ORA-00942: table or view does not exist
SELECT * FROM "Employees"; -- درستهرگز جدولها و ستونها را با دابلکوت و حروف مخلوط نساز. یک 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 با 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()
);CREATE TABLE customer (
id NUMBER GENERATED ALWAYS AS IDENTITY,
email VARCHAR2(320 CHAR) NOT NULL,
full_name VARCHAR2(200 CHAR) NOT NULL,
credit_limit NUMBER(12,2) DEFAULT 0 NOT NULL, -- DEFAULT قبل از NOT NULL
is_active NUMBER(1) DEFAULT 1 NOT NULL, -- 23ai: BOOLEAN DEFAULT TRUE
attributes CLOB CHECK (attributes IS JSON), -- 21c+: نوع بومی JSON
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_customer PRIMARY KEY (id),
CONSTRAINT uq_customer_email UNIQUE (email)
);در 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 استفاده میکنی. این ناهمخوانی یکی از پرتکرارترین باگهای مهاجرت است.
در 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; -- درصدِ درستSELECT 1 / 2 FROM DUAL; -- 0.5 (همهچیز NUMBER است)
SELECT 1.0 / 2 FROM DUAL; -- 0.5
SELECT COUNT(*) / total FROM stat GROUP BY total; -- همیشه اعشاریدر 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 '' = ''; -- trueSELECT CASE WHEN '' IS NULL THEN 'yes' ELSE 'no' END FROM DUAL; -- yes
SELECT LENGTH('') FROM DUAL; -- NULL (نه صفر!)
SELECT COUNT(*) FROM customer WHERE full_name = ''; -- همیشه 0Oracle رشتهی خالی '' را با NULL یکسان میداند. این رفتار تاریخی است و Oracle خودش گفته شاید روزی عوض شود، پس نه روی «'' برابر NULL میماند» حساب کن و نه روی «روزی جدا میشود». در PostgreSQL و استاندارد SQL، رشتهی خالی یک مقدارِ معتبر و متفاوت از NULL است.
سناریوی واقعی: فرم یک middleName خالی میفرستد و اپ "" را در ستونِ NOT NULL مینویسد. روی PostgreSQL بیمشکل ذخیره میشود؛ همان کد روی Oracle با ORA-01400: cannot insert NULL میترکد چون Oracle آن "" را NULL دیده. تیم ساعتها دنبال «NULL از کجا آمد؟» میگردد در حالیکه کد اصلاً NULL نفرستاده. درمان: در مرزِ ورودی تصمیم بگیر — یا خالیها را به null نرمال کن و ستون را nullable کن، یا یک پیشفرضِ معنادار بگذار.
چون 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; -- خودِ ستون یک عبارتِ بولین استCREATE TABLE account (
id NUMBER GENERATED ALWAYS AS IDENTITY,
is_active NUMBER(1) DEFAULT 0 NOT NULL
CONSTRAINT ck_active CHECK (is_active IN (0, 1)),
CONSTRAINT pk_account PRIMARY KEY (id)
);
-- Oracle 23ai: is_active BOOLEAN DEFAULT FALSE NOT NULL
SELECT * FROM account WHERE is_active = 1; -- تا 19c باید مقایسه کنیاگر روی 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;-- سبک بومی Oracle (غیرقابلحمل)
SELECT NVL(phone, 'N/A') AS contact,
DECODE(status, 'A', 'Active', 'I', 'Inactive', 'Unknown') AS status_label
FROM customer;
-- همان کوئری به سبک استاندارد — این را بنویس
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;NVL فقط دو آرگومان میگیرد؛ COALESCE هر تعداد و اولین غیر-NULL را برمیگرداند. مهمتر: COALESCE تنبل است و بهمحض یافتن اولین غیر-NULL بقیه را ارزیابی نمیکند، ولی NVL هر دو آرگومان را ارزیابی میکند — پس NVL(x, expensive_function()) تابع گران را حتی وقتی x غیر-NULL است اجرا میکند. برای قابلیت حمل و کارایی، پیشفرضت COALESCE/CASE باشد؛ DECODE را فقط در کد قدیمی نگهدار.
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 در دو موتور فرق دارد. واقعیت: هر دو در 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', '@'); -- 2SELECT first_name || ' ' || last_name AS full_name FROM person;
-- Oracle SUBSTRING استاندارد را ندارد
SELECT SUBSTR('PostgreSQL', 1, 4) FROM DUAL; -- 'Post' (اندیس از 1)
-- Oracle POSITION ندارد؛ INSTR معادلش است
SELECT INSTR('a@b.com', '@') FROM DUAL; -- 2 (0 اگر نبود)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);SELECT * FROM person WHERE REGEXP_LIKE(email, '^[a-z0-9._%+-]+@example\.com$', 'i');
SELECT REGEXP_REPLACE('a b c', '[[:space:]]+', ' ') FROM DUAL; -- 'a b c'
-- جستوجوی بیتوجه به حروف با ایندکس تابعی (قابل حمل)
CREATE INDEX idx_person_email_lower ON person (LOWER(email));
SELECT * FROM person WHERE LOWER(email) = LOWER(:input);هر دو راه بومی دارند — 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()SELECT SYSDATE FROM DUAL; -- DATE، زمان سرور، هر بار تازه
SELECT SYSTIMESTAMP FROM DUAL; -- TIMESTAMP WITH TIME ZONE سرور
SELECT CURRENT_TIMESTAMP FROM DUAL; -- منطقهی زمانیِ sessionSYSDATE زمانِ سرور دیتابیس را میدهد و هر بار تازه است. 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 TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') FROM DUAL;
SELECT TO_DATE('2026-07-23', 'YYYY-MM-DD') FROM DUAL;
SELECT DATE '2026-07-23' FROM DUAL; -- literal استاندارد
SELECT TIMESTAMP '2026-07-23 14:30:00' FROM DUAL;حسابِ تاریخ اما جایی است که گویشها واقعاً از هم جدا میشوند:
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; -- تفاضل = intervalSELECT SYSDATE + 1 FROM DUAL; -- فردا (عدد = روز)
SELECT ADD_MONTHS(SYSDATE, 1) FROM DUAL; -- معادل + interval '1 month'
SELECT TRUNC(SYSDATE, 'MM') FROM DUAL; -- اول ماه
SELECT MONTHS_BETWEEN(SYSDATE, created_at) FROM customer;
SELECT (end_at - start_at) FROM job_run; -- تفاضل دو DATE = عددِ روز!در 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;SELECT sku FROM inventory
MINUS -- نام تاریخیِ Oracle، تنها گزینه تا 19c
SELECT sku FROM discontinued;
-- Oracle 21c+ : EXCEPT / EXCEPT ALL / MINUS ALL / INTERSECT ALL هم اضافه شدند
SELECT sku FROM inventory
EXCEPT ALL
SELECT sku FROM discontinued;تا 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;-- Oracle 12c+ : فقط فرم استاندارد ANSI
SELECT id, name FROM product
ORDER BY created_at DESC, id DESC
OFFSET 40 ROWS FETCH FIRST 20 ROWS ONLY;
-- Oracle اصلاً LIMIT ندارد:
-- SELECT id, name FROM product LIMIT 20 OFFSET 40; -- ORA-00933OFFSET 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;-- Oracle قدیمی: الگوی ROWNUM سهلایه
SELECT * FROM (
SELECT inner_q.*, ROWNUM rn FROM (
SELECT id, name FROM product
ORDER BY created_at DESC, id DESC
) inner_q
WHERE ROWNUM <= 60 -- offset + limit
)
WHERE rn > 40; -- offsetROWNUM قبل از 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 مقایسهی تاپلی با < را ندارد؛ باید بازش کنی
SELECT id, name FROM product
WHERE created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;تفاوتی کمگفتهشده و پرمصرف: در 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 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]
Pageable را به repository بده و Hibernate با توجه به Dialect خودش LIMIT/OFFSET یا FETCH FIRST مناسب را تولید میکند؛ یعنی repo.findAll(PageRequest.of(2, 20, Sort.by("createdAt").descending())) روی هر دو کار میکند. فقط برای صفحهبندیِ عمیق، Slice (بدون count کوئریِ گران) یا keyset دستی را در نظر بگیر.
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);CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY,
total NUMBER(12,2) NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (id)
);
-- حالتها: ALWAYS | BY DEFAULT | BY DEFAULT ON NULL
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 است-- NEXTVAL/CURRVAL شبهستوناند
CREATE SEQUENCE seq_order START WITH 1 INCREMENT BY 1 CACHE 100 NOORDER;
INSERT INTO orders (id, total) VALUES (seq_order.NEXTVAL, 99.90);
SELECT seq_order.CURRVAL FROM DUAL;
-- پیشفرض CACHE در Oracle برابر 20 و NOORDER است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 مستقلALTER SEQUENCE seq_order RESTART START WITH 1001; -- از 12.2
-- برای ستون identity، شمارنده را تا بیشترین مقدار جلو ببر
ALTER TABLE orders MODIFY (id GENERATED ALWAYS AS IDENTITY (START WITH LIMIT VALUE));@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 دیتابیس هماهنگ کن.
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 هم داری اگر بخواهی برخورد را بیصدا رد کنی-- Oracle: MERGE (استاندارد SQL، از 9i) — ON CONFLICT ندارد
MERGE INTO inventory t
USING (SELECT 'ABC-1' AS sku, 10 AS qty FROM DUAL) s
ON (t.sku = s.sku)
WHEN MATCHED THEN
UPDATE SET t.qty = t.qty + s.qty, t.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (sku, qty, updated_at) VALUES (s.sku, s.qty, SYSTIMESTAMP);در PostgreSQL EXCLUDED ردیفِ پیشنهادیِ درج است؛ در Oracle جدولِ منبع (USING … s) همان نقش را دارد.
MERGE استانداردِ ANSI است و PostgreSQL هم از نسخهی 15 آن را دارد، پس میتوانی MERGE را بهعنوان راهِ قابلحملِ upsert روی هر دو بنویسی؛ ولی ON CONFLICT فقط PostgreSQL است. دو تفاوت نحوی که در انتقال کد گاز میگیرد: (۱) Oracle شرط ON را حتماً داخل پرانتز میخواهد؛ (۲) در Oracle در UPDATE SET نمیتوانی ستونی را که در شرط ON آمده تغییر دهی (ORA-38104)، در PostgreSQL چنین محدودیتی نیست.
MERGE بهخودیِخود ضدِّ همزمانی نیست. اگر دو تراکنش همزمان برای یک کلیدِ ناموجود MERGE بزنند، هر دو ممکن است شاخهی NOT MATCHED را بروند و بعد یکی با خطای unique شکست بخورد (یا در بدترین حالت دو ردیف بماند). راه درست: UNIQUE روی کلید + retry در اپ، یا قفلِ صریح. در PostgreSQL INSERT … ON CONFLICT این رقابت را اتمیک حل میکند و برای upsertِ پرتراکنش امنتر است. این تفاوت را در مصاحبه بلد باش — نشانهی درک عمیقِ همزمانی است.
اگر کوئریِ 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;-- Oracle 23ai: MERGE با RETURNING و مقادیر OLD/NEW
DECLARE
v_old NUMBER; v_new NUMBER;
BEGIN
MERGE INTO inventory t
USING (SELECT 'ABC-1' AS sku, 10 AS qty FROM DUAL) s
ON (t.sku = s.sku)
WHEN MATCHED THEN UPDATE SET t.qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
RETURNING OLD t.qty, NEW t.qty INTO v_old, v_new;
END;
/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]
در 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 با RETURNING (و merge_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;-- RETURNING ... INTO برای PL/SQL و bind variable است
DECLARE
v_id orders.id%TYPE;
v_total orders.total%TYPE;
BEGIN
INSERT INTO orders (total) VALUES (99.90) RETURNING id INTO v_id;
UPDATE orders SET total = total * 1.1 WHERE id = v_id
RETURNING total INTO v_total;
END;
/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();
}
روی Oracle اگر Statement.RETURN_GENERATED_KEYS بدهی، درایور ROWID را برمیگرداند نه ستون id، و کدت با خطای تبدیل نوع یا مقدار عجیب میشکند. راهِ درست و قابلحمل همان است که در کد بالا دیدی: نامِ ستون کلید را صریح بده. روی PostgreSQL هم همین فرم کار میکند و درایور آن را به RETURNING id ترجمه میکند. این یکی از آن جزئیاتی است که فقط وقتی کد را از PostgreSQL به Oracle میبری کشف میشود.
اگر فقط کلیدِ تولیدشده را میخواهی، سراغِ 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;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;خیلی از منطقهایی که junior با چند کوئری و حلقهی Java حل میکند (رتبهبندی، «آخرین رکورد هر گروه»، جمع تجمعی، تفاوت با ردیف قبل)، با یک window function در یک کوئریِ قابلحمل حل میشود. یاد گرفتنش تفاوتِ آشکارِ mid و senior در مصاحبه است.
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;-- معادلِ DISTINCT ON با window function استاندارد
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) rn
FROM events e
) WHERE rn = 1;
-- Oracle کلاز FILTER ندارد: CASE داخل تجمیع
SELECT COUNT(*) AS total,
COUNT(CASE WHEN status = 'PAID' THEN 1 END) 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;SELECT department_id,
LISTAGG(last_name, ', ' ON OVERFLOW TRUNCATE '...')
WITHIN GROUP (ORDER BY last_name) AS names
FROM employees
GROUP BY department_id;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;-- سبک بومی Oracle: CONNECT BY
SELECT LEVEL AS lvl,
SYS_CONNECT_BY_PATH(name, '/') AS path,
name
FROM employee
START WITH manager_id IS NULL -- ریشه
CONNECT BY PRIOR id = manager_id -- گام بازگشتی
ORDER SIBLINGS BY name;
-- Oracle از 11gR2 فرم استاندارد WITH را هم دارد (بدون کلمهی RECURSIVE)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 نوشته نمیشود.
در 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');CREATE TABLE doc (id NUMBER GENERATED ALWAYS AS IDENTITY, body JSON);
-- پیش از 21c: body CLOB CHECK (body IS JSON)
SELECT d.body.name AS name, -- نحوِ نقطهای (نیازمند alias)
JSON_VALUE(d.body, '$.address.city') AS city,
JSON_QUERY(d.body, '$.tags') AS tags
FROM doc d
WHERE JSON_EXISTS(d.body, '$?(@.type == "customer")');
CREATE SEARCH INDEX idx_doc_body ON doc (body) FOR JSON;
-- همان توابع استاندارد، سازگار با PostgreSQL 17
SELECT JSON_VALUE(body, '$.address.city') FROM doc;توابع استانداردِ 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 عالی است برای دادهی نیمهساختیافته و واقعاً متغیر (تنظیمات، 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 درست-- تعریف دائمی است و در دیکشنری میماند؛ فقط داده session-محور است
CREATE GLOBAL TEMPORARY TABLE stage_order (
id NUMBER, total NUMBER(12,2)
) ON COMMIT DELETE ROWS; -- یا ON COMMIT PRESERVE ROWS
INSERT INTO stage_order SELECT id, total FROM orders
WHERE created_at > SYSDATE - 1;
-- Oracle 18c+ : CREATE PRIVATE TEMPORARY TABLE ora$ptt_stage AS SELECT ...تفاوت مفهومی: در 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;-- سطوح: READ COMMITTED (پیشفرض)، SERIALIZABLE، READ ONLY — بدون REPEATABLE READ
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(total) FROM orders;
COMMIT;
SELECT tablespace_name, status, SUM(bytes) FROM dba_undo_extents
GROUP BY tablespace_name, status;اگر در 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» ندارد-- همان الگو، همان نحو
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; -- ORA-00054
SELECT * FROM orders WHERE id = 1 FOR UPDATE WAIT 5; -- ۵ ثانیه صبر کنیکی از عمیقترین تفاوتها، با اثر مستقیم روی طراحیِ 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; -- هیچ اثری باقی نمیماند-- هر DDL خودش commit میکند؛ ROLLBACK بیاثر است
ALTER TABLE orders ADD (note VARCHAR2(500 CHAR)); -- commit ضمنی
CREATE INDEX idx_orders_note ON orders (note); -- commit ضمنی
ROLLBACK; -- هیچ چیزی برنمیگرددتفاوت در جای نگهداریِ نسخهی قدیمی است. 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"ALTER TABLE customer ADD CONSTRAINT uq_customer_email UNIQUE (email);
-- پیام: ORA-00001: unique constraint (APP.UQ_CUSTOMER_EMAIL) violatedSpring خطاهای هر دو را به سلسلهمراتبِ DataAccessException نگاشت میکند: برای PostgreSQL از روی SQLSTATE و برای Oracle از روی error codeهای sql-error-codes.xml. پس در سرویس هرگز روی ORA-00001 یا متنِ پیام شرط نگذار؛ catch (DuplicateKeyException e) بنویس تا کد روی هر دو کار کند. برای خطاهای گذرا (CannotAcquireLockException, DeadlockLoserDataAccessException) از Spring Retry با backoff استفاده کن. اگر نامِ قید را میخواهی، در 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 PLAN FOR SELECT * FROM orders WHERE customer_id = 42; -- تخمینی
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
-- plan واقعی با آمار اجرا — این را بنویس
SELECT /*+ GATHER_PLAN_STATISTICS */ * FROM orders WHERE customer_id = 42;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST'));
BEGIN DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS'); END;
/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 را نصب کنی-- Oracle hintهای غنی و رسمی دارد
SELECT /*+ INDEX(o idx_orders_customer) */ * FROM orders o WHERE o.customer_id = 42;
SELECT /*+ LEADING(c o) USE_NL(o) */ *
FROM customer c JOIN orders o ON o.customer_id = c.id;
SELECT /*+ PARALLEL(o, 4) */ COUNT(*) FROM orders o;دو هشدارِ سنیور. اول: 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';-- گرانترین کوئریها (بدون نیاز به لایسنس اضافه)
SELECT executions, elapsed_time/GREATEST(executions,1) AS avg_us, sql_text
FROM v$sql ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;
SELECT sid, status, event, sql_id
FROM v$session WHERE status = 'ACTIVE';مسیر فکریام یکی است، ابزار فرق میکند. اول 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);CREATE OR REPLACE FUNCTION order_total(p_id NUMBER)
RETURN NUMBER
IS
v_total NUMBER := 0;
BEGIN
SELECT NVL(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) FROM DUAL;(۱) package — CREATE 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 نشسته باشد، مهاجرت به 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; -- بعد از بارگذاری انبوه، آمار را تازه کن-- direct-path insert با hint اختصاصی APPEND
INSERT /*+ APPEND */ INTO orders (id, customer_id, total)
SELECT id, customer_id, total FROM ext_orders;
COMMIT; -- تا commit نکنی جدول قفل است
-- بیرون از SQL: SQL*Loader یا external table
BEGIN DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS'); END;
/اگر با 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 با 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 میآزماید
}
در Flyway پوشههای db/migration/{vendor} جدا نگهدار (flyway.locations را با placeholder بساز) یا در Liquibase از dbms="oracle"/dbms="postgresql" روی هر changeSet استفاده کن. تلاش برای نوشتن یک اسکریپتِ واحد که هر دو را راضی کند معمولاً به کمترین مخرج مشترکِ ضعیف ختم میشود. و یادت باشد در Oracle بهخاطر commit ضمنیِ DDL، هر اسکریپت باید قابلِ ازسرگیری باشد.
لایهلایه: (۱) برای اکثریتِ دسترسیها روی 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ِ بالغ جایگزین جذابی شده. ریسکهای فنی: (۱) کدِ 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.
با MERGE، اگر دو تراکنش همزمان برای کلیدِ ناموجود اجرا شوند، هر دو در لحظهی خواندن آن را «موجود نیست» میبینند و شاخهی WHEN NOT MATCHED THEN INSERT را میروند؛ اولی commit میکند و دومی با ORA-00001 میشکند — بهشرط داشتنِ قید یکتا، که حتماً باید باشد وگرنه دو ردیفِ تکراری میماند. امنسازی: (۱) UNIQUE روی کلیدِ merge تا بدترین حالت «خطا» باشد نه «دادهی خراب»؛ (۲) خطای برخورد را در اپ بگیر و retry کن (بار دوم شاخهی MATCHED میرود)؛ (۳) یا پیش از merge با SELECT … FOR UPDATE قفلِ ردیف بگیر. در PostgreSQL راهِ تمیزتر INSERT … ON CONFLICT DO UPDATE است که ذاتاً اتمیک این رقابت را حل میکند و نیاز به retry ندارد.
نه، دو تفاوت مهمتر هست. اول، COALESCE استانداردِ ANSI است و روی هر دو کار میکند، در حالیکه NVL اختصاصیِ Oracle است. دوم و ظریفتر: COALESCE short-circuit است و بهمحض رسیدن به اولین آرگومانِ غیر-NULL بقیه را ارزیابی نمیکند، اما NVL هر دو آرگومان را همیشه ارزیابی میکند — پس NVL(x, slow_func()) تابعِ گران را حتی وقتی x غیر-NULL است اجرا میکند. سوم، NVL دو آرگومان میگیرد ولی COALESCE هر تعداد. نکتهی تکمیلی: NVL نوعِ آرگومان دوم را به نوعِ اولی تبدیل میکند و میتواند خطای تبدیل بدهد. جمعبندی: COALESCE هم قابلحملتر است هم بالقوه کاراتر.
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 همیشه شاملِ ساعت/دقیقه/ثانیه است، پس 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) همان مشکل ایندکس را دارد). درسِ کلی: برای فیلترِ روزانه روی ستونهای زماندار، همیشه بازهی نیمباز.
چون sequenceها برای یکتایی و کارایی طراحی شدهاند، نه پیوستگی. با CACHE n هر instance بلوکی میگیرد و اگر دیتابیس ریاستارت شود یا نودی بمیرد، باقیِ بلوک برای همیشه گم میشود؛ تراکنشهای rollbackشده هم شمارهی مصرفشده را برنمیگردانند. پیشفرضها فرق دارند: Oracle CACHE 20 و PostgreSQL CACHE 1، پس بعد از مهاجرت به Oracle ناگهان شکافها بیشتر دیده میشوند. در RAC چند-نودی، بدون ORDER حتی ترتیبِ صعودیِ سراسری هم تضمین نیست. اگر به شمارهی قانوناً پیوسته نیاز داری (شمارهی فاکتور)، آن را جدا با یک جدولِ counter و قفل مدیریت کن — کندتر است ولی پیوستگی میدهد. برای کلیدِ اصلیِ فنی، شکاف کاملاً بیاهمیت است.
TIMESTAMP (without time zone) یک تاریخ-زمانِ بدونِ آگاهی از منطقه است و همان اعداد را ذخیره و برمیگرداند. TIMESTAMPTZ مقدار را در UTC ذخیره میکند و هنگام خواندن به منطقهی زمانیِ session تبدیل میکند — یعنی یک لحظهی مطلق را نمایندگی میکند؛ برای رویدادهای واقعی (زمان ثبت سفارش، لاگ) همیشه همین درست است. معادل Oracle، TIMESTAMP WITH TIME ZONE است، و TIMESTAMP WITH LOCAL TIME ZONE که هنگام ذخیره به منطقهی دیتابیس نرمال میکند و هنگام خواندن به منطقهی session برمیگرداند، نزدیکترین چیز به رفتار timestamptz است. در Java به Instant یا OffsetDateTime نگاشت کن نه LocalDateTime. قاعدهی طلایی: ذخیره در UTC، نمایش با منطقهی کاربر.
با یک 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;SELECT * FROM (
SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) rn
FROM events t
) WHERE rn = 1;ROW_NUMBER() در هر گروهِ user_id ردیفها را بر اساس ts نزولی شماره میزند و ما ردیفِ ۱ (جدیدترین) را نگه میداریم. تنها تفاوت این است که PostgreSQL برای سابکوئریِ داخل FROM نام مستعار میخواهد. در PostgreSQL میشود همین را با DISTINCT ON (user_id) … ORDER BY user_id, ts DESC کوتاهتر نوشت که خواناتر و گاهی سریعتر است ولی اختصاصی است. اگر چند ردیف با ts برابر باشند و همه را بخواهم، RANK() میگذارم.
چون 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شاملِ زمان؛ در PostgreSQL1/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.
- 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/DECODEvsCOALESCE/CASE, regex,INTERVAL. - Sets and pagination:
MINUS/EXCEPT,FETCH FIRST,ROWNUM, keyset. - Keys, upsert and RETURNING:
IDENTITY,SEQUENCE,ON CONFLICTvsMERGE. - Advanced: window functions,
LISTAGG/STRING_AGG,CONNECT BYvs recursive CTE, JSON, temp tables. - The engine: MVCC and undo, isolation, locking, transactional DDL,
ORA-01555vs 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:
- The standard core (ANSI SQL):
SELECT/INSERT/UPDATE/DELETE,JOIN,GROUP BY, window functions,CASE,COALESCE. Write these anywhere with confidence. - 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.
- Fully proprietary extensions:
DECODE,CONNECT BYand hints are Oracle's;DISTINCT ON,FILTERand native arrays are PostgreSQL's.
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.
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;SELECT SYSDATE FROM DUAL;
SELECT 1 + 1 FROM DUAL;
-- Oracle 23ai: FROM is no longer mandatory
-- SELECT SYSDATE;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 (Employees → EMPLOYEES) and PostgreSQL folds it to lowercase (Employees → employees). 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"; -- correctCREATE TABLE "Employees" (id NUMBER); -- the name is exactly Employees, not EMPLOYEES
SELECT * FROM Employees; -- ORA-00942: table or view does not exist
SELECT * FROM "Employees"; -- correctNever 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.
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'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()
);CREATE TABLE customer (
id NUMBER GENERATED ALWAYS AS IDENTITY,
email VARCHAR2(320 CHAR) NOT NULL,
full_name VARCHAR2(200 CHAR) NOT NULL,
credit_limit NUMBER(12,2) DEFAULT 0 NOT NULL, -- DEFAULT before NOT NULL
is_active NUMBER(1) DEFAULT 1 NOT NULL, -- 23ai: BOOLEAN DEFAULT TRUE
attributes CLOB CHECK (attributes IS JSON), -- 21c+: native JSON type
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL,
CONSTRAINT pk_customer PRIMARY KEY (id),
CONSTRAINT uq_customer_email UNIQUE (email)
);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.
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 percentageSELECT 1 / 2 FROM DUAL; -- 0.5 (everything is NUMBER)
SELECT 1.0 / 2 FROM DUAL; -- 0.5
SELECT COUNT(*) / total FROM stat GROUP BY total; -- always fractionalIn 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 '' = ''; -- trueSELECT CASE WHEN '' IS NULL THEN 'yes' ELSE 'no' END FROM DUAL; -- yes
SELECT LENGTH('') FROM DUAL; -- NULL (not zero!)
SELECT COUNT(*) FROM customer WHERE full_name = ''; -- always 0Oracle 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.
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.
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 expressionCREATE TABLE account (
id NUMBER GENERATED ALWAYS AS IDENTITY,
is_active NUMBER(1) DEFAULT 0 NOT NULL
CONSTRAINT ck_active CHECK (is_active IN (0, 1)),
CONSTRAINT pk_account PRIMARY KEY (id)
);
-- Oracle 23ai: is_active BOOLEAN DEFAULT FALSE NOT NULL
SELECT * FROM account WHERE is_active = 1; -- through 19c you must compareIf 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;-- Oracle-native style (not portable)
SELECT NVL(phone, 'N/A') AS contact,
DECODE(status, 'A', 'Active', 'I', 'Inactive', 'Unknown') AS status_label
FROM customer;
-- the same query in standard style — write this one
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;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 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.
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', '@'); -- 2SELECT first_name || ' ' || last_name AS full_name FROM person;
-- Oracle has no standard SUBSTRING
SELECT SUBSTR('PostgreSQL', 1, 4) FROM DUAL; -- 'Post' (1-based)
-- Oracle has no POSITION; INSTR is the equivalent
SELECT INSTR('a@b.com', '@') FROM DUAL; -- 2 (0 if absent)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);SELECT * FROM person WHERE REGEXP_LIKE(email, '^[a-z0-9._%+-]+@example\.com$', 'i');
SELECT REGEXP_REPLACE('a b c', '[[:space:]]+', ' ') FROM DUAL; -- '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);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.
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()SELECT SYSDATE FROM DUAL; -- DATE, server time, fresh every call
SELECT SYSTIMESTAMP FROM DUAL; -- server TIMESTAMP WITH TIME ZONE
SELECT CURRENT_TIMESTAMP FROM DUAL; -- the session's time zoneSYSDATE 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';SELECT TO_CHAR(SYSDATE, 'YYYY-MM-DD HH24:MI:SS') FROM DUAL;
SELECT TO_DATE('2026-07-23', 'YYYY-MM-DD') FROM DUAL;
SELECT DATE '2026-07-23' FROM DUAL; -- standard literal
SELECT TIMESTAMP '2026-07-23 14:30:00' FROM DUAL;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 = intervalSELECT SYSDATE + 1 FROM DUAL; -- tomorrow (a number = days)
SELECT ADD_MONTHS(SYSDATE, 1) FROM DUAL; -- the + interval '1 month' equivalent
SELECT TRUNC(SYSDATE, 'MM') FROM DUAL; -- start of month
SELECT MONTHS_BETWEEN(SYSDATE, created_at) FROM customer;
SELECT (end_at - start_at) FROM job_run; -- difference of two DATEs = days!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).
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;SELECT sku FROM inventory
MINUS -- Oracle's historical name, the only option through 19c
SELECT sku FROM discontinued;
-- Oracle 21c+ also added EXCEPT / EXCEPT ALL / MINUS ALL / INTERSECT ALL
SELECT sku FROM inventory
EXCEPT ALL
SELECT sku FROM discontinued;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;-- Oracle 12c+ : only the ANSI standard form
SELECT id, name FROM product
ORDER BY created_at DESC, id DESC
OFFSET 40 ROWS FETCH FIRST 20 ROWS ONLY;
-- Oracle has no LIMIT at all:
-- SELECT id, name FROM product LIMIT 20 OFFSET 40; -- ORA-00933OFFSET 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;-- Legacy Oracle: the triple-nested ROWNUM pattern
SELECT * FROM (
SELECT inner_q.*, ROWNUM rn FROM (
SELECT id, name FROM product
ORDER BY created_at DESC, id DESC
) inner_q
WHERE ROWNUM <= 60 -- offset + limit
)
WHERE rn > 40; -- offsetROWNUM 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 has no row-value comparison with <; you must expand it
SELECT id, name FROM product
WHERE created_at < :last_created_at
OR (created_at = :last_created_at AND id < :last_id)
ORDER BY created_at DESC, id DESC
FETCH FIRST 20 ROWS ONLY;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.
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]
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.
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);CREATE TABLE orders (
id NUMBER GENERATED ALWAYS AS IDENTITY,
total NUMBER(12,2) NOT NULL,
CONSTRAINT pk_orders PRIMARY KEY (id)
);
-- modes: ALWAYS | BY DEFAULT | BY DEFAULT ON NULL
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-- NEXTVAL/CURRVAL are pseudo-columns
CREATE SEQUENCE seq_order START WITH 1 INCREMENT BY 1 CACHE 100 NOORDER;
INSERT INTO orders (id, total) VALUES (seq_order.NEXTVAL, 99.90);
SELECT seq_order.CURRVAL FROM DUAL;
-- Oracle's defaults are CACHE 20 and NOORDERCACHE 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 sequenceALTER SEQUENCE seq_order RESTART START WITH 1001; -- since 12.2
-- for an identity column, advance the counter to the max value
ALTER TABLE orders MODIFY (id GENERATED ALWAYS AS IDENTITY (START WITH LIMIT VALUE));@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.
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-- Oracle: MERGE (SQL standard, since 9i) — there is no ON CONFLICT
MERGE INTO inventory t
USING (SELECT 'ABC-1' AS sku, 10 AS qty FROM DUAL) s
ON (t.sku = s.sku)
WHEN MATCHED THEN
UPDATE SET t.qty = t.qty + s.qty, t.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (sku, qty, updated_at) VALUES (s.sku, s.qty, SYSTIMESTAMP);In PostgreSQL EXCLUDED is the row proposed for insertion; in Oracle the source table (USING … s) plays the same role.
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 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.
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;-- Oracle 23ai: MERGE with RETURNING and OLD/NEW values
DECLARE
v_old NUMBER; v_new NUMBER;
BEGIN
MERGE INTO inventory t
USING (SELECT 'ABC-1' AS sku, 10 AS qty FROM DUAL) s
ON (t.sku = s.sku)
WHEN MATCHED THEN UPDATE SET t.qty = t.qty + s.qty
WHEN NOT MATCHED THEN INSERT (sku, qty) VALUES (s.sku, s.qty)
RETURNING OLD t.qty, NEW t.qty INTO v_old, v_new;
END;
/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]
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;-- RETURNING ... INTO targets PL/SQL variables and binds
DECLARE
v_id orders.id%TYPE;
v_total orders.total%TYPE;
BEGIN
INSERT INTO orders (total) VALUES (99.90) RETURNING id INTO v_id;
UPDATE orders SET total = total * 1.1 WHERE id = v_id
RETURNING total INTO v_total;
END;
/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();
}
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.
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;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;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.
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 DISTINCT ON equivalent with a standard window function
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) rn
FROM events e
) WHERE rn = 1;
-- Oracle has no FILTER clause: CASE inside the aggregate
SELECT COUNT(*) AS total,
COUNT(CASE WHEN status = 'PAID' THEN 1 END) 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;SELECT department_id,
LISTAGG(last_name, ', ' ON OVERFLOW TRUNCATE '...')
WITHIN GROUP (ORDER BY last_name) AS names
FROM employees
GROUP BY department_id;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.
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;-- Oracle-native style: CONNECT BY
SELECT LEVEL AS lvl,
SYS_CONNECT_BY_PATH(name, '/') AS path,
name
FROM employee
START WITH manager_id IS NULL -- the roots
CONNECT BY PRIOR id = manager_id -- the recursive step
ORDER SIBLINGS BY name;
-- Oracle has supported the standard WITH form since 11gR2 (no RECURSIVE keyword)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.
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');CREATE TABLE doc (id NUMBER GENERATED ALWAYS AS IDENTITY, body JSON);
-- before 21c: body CLOB CHECK (body IS JSON)
SELECT d.body.name AS name, -- dot notation (needs an alias)
JSON_VALUE(d.body, '$.address.city') AS city,
JSON_QUERY(d.body, '$.tags') AS tags
FROM doc d
WHERE JSON_EXISTS(d.body, '$?(@.type == "customer")');
CREATE SEARCH INDEX idx_doc_body ON doc (body) FOR JSON;
-- the same standard functions, compatible with PostgreSQL 17
SELECT JSON_VALUE(body, '$.address.city') FROM doc;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.
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-- the definition is permanent and lives in the dictionary; only data is session-scoped
CREATE GLOBAL TEMPORARY TABLE stage_order (
id NUMBER, total NUMBER(12,2)
) ON COMMIT DELETE ROWS; -- or ON COMMIT PRESERVE ROWS
INSERT INTO stage_order SELECT id, total FROM orders
WHERE created_at > SYSDATE - 1;
-- Oracle 18c+ : CREATE PRIVATE TEMPORARY TABLE ora$ptt_stage AS SELECT ...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;-- levels: READ COMMITTED (default), SERIALIZABLE, READ ONLY — no REPEATABLE READ
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(total) FROM orders;
COMMIT;
SELECT tablespace_name, status, SUM(bytes) FROM dba_undo_extents
GROUP BY tablespace_name, status;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"-- same pattern, same syntax
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; -- ORA-00054
SELECT * FROM orders WHERE id = 1 FOR UPDATE WAIT 5; -- wait 5 secondsOne 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-- every DDL commits itself; ROLLBACK is a no-op
ALTER TABLE orders ADD (note VARCHAR2(500 CHAR)); -- implicit commit
CREATE INDEX idx_orders_note ON orders (note); -- implicit commit
ROLLBACK; -- nothing is undoneThe 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"ALTER TABLE customer ADD CONSTRAINT uq_customer_email UNIQUE (email);
-- message: ORA-00001: unique constraint (APP.UQ_CUSTOMER_EMAIL) violatedSpring 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.
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 statisticsEXPLAIN PLAN FOR SELECT * FROM orders WHERE customer_id = 42; -- estimated
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);
-- the real plan with execution statistics — write this one
SELECT /*+ GATHER_PLAN_STATISTICS */ * FROM orders WHERE customer_id = 42;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(FORMAT => 'ALLSTATS LAST'));
BEGIN DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS'); END;
/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-- Oracle has rich, official hints
SELECT /*+ INDEX(o idx_orders_customer) */ * FROM orders o WHERE o.customer_id = 42;
SELECT /*+ LEADING(c o) USE_NL(o) */ *
FROM customer c JOIN orders o ON o.customer_id = c.id;
SELECT /*+ PARALLEL(o, 4) */ COUNT(*) FROM orders o;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';-- the most expensive queries (no extra licence needed)
SELECT executions, elapsed_time/GREATEST(executions,1) AS avg_us, sql_text
FROM v$sql ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;
SELECT sid, status, event, sql_id
FROM v$session WHERE status = 'ACTIVE';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);CREATE OR REPLACE FUNCTION order_total(p_id NUMBER)
RETURN NUMBER
IS
v_total NUMBER := 0;
BEGIN
SELECT NVL(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) FROM DUAL;(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 transactions — PRAGMA 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.
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-- direct-path insert with the proprietary APPEND hint
INSERT /*+ APPEND */ INTO orders (id, customer_id, total)
SELECT id, customer_id, total FROM ext_orders;
COMMIT; -- the table stays locked until you commit
-- outside SQL: SQL*Loader or an external table
BEGIN DBMS_STATS.GATHER_TABLE_STATS(USER, 'ORDERS'); END;
/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
- ORM/Hibernate with the right Dialect: automates most CRUD, pagination, key generation and type mapping.
- jOOQ for type-safe SQL: generates portable but explicit SQL (it even expands the keyset tuple comparison for Oracle).
- Flyway/Liquibase for migrations: versions your DDL; Liquibase can even produce database-agnostic changelogs.
- 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
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
}
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.
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.
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.
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.
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.
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.
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.
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.
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.
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;SELECT * FROM (
SELECT t.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY ts DESC) rn
FROM events t
) 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.
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
- 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
''isNULLandDATEcarries time; in PostgreSQL1/2is zero andnow() + 1is an error. - Pagination:
OFFSET … FETCH FIRSTis standard and portable;ROWNUMis legacy and dangerous withORDER BY; for depth use keyset — but remember Oracle has no(a,b) < (x,y)tuple comparison. - Keys:
GENERATED AS IDENTITYis modern and shared; for bulk insert use SEQUENCE withallocationSize; theCACHEdefault is 20 on Oracle and 1 on PostgreSQL, and never rely on id contiguity. - Upsert:
ON CONFLICTis atomic and race-safe (PostgreSQL-only);MERGEis portable (PG15+/Oracle) but needs UNIQUE + retry + a de-duplicated source (ORA-30926).RETURNINGin MERGE since PG17 (withmerge_action()) and Oracle 23ai (withOLD/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 withEXPLAIN (ANALYZE, BUFFERS)vsDBMS_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.