Databases & SQL · پایگاهداده و SQL متوسطIntermediate ~93 دقیقه مطالعه~81 min read
تسلط بر SQL: Join، Window، CTE و ایندکسSQL Mastery: Joins, Windows, CTEs & Indexes
از صفر تا قهرمان یاد میگیری که SQL چطور «چه میخواهی» را به یک پلن اجرا ترجمه میکند، و با تشبیههای ساده و مثالهای دوزبانه در **PostgreSQL** و **Oracle** بر Joinها، Window Functionها، CTEها (و CONNECT BY)، صفحهبندی با ROWNUM و FETCH FIRST، ایندکسها و خواندن EXPLAIN ANALYZE در برابر EXPLAIN PLAN مسلط میشوی.A zero-to-hero, analogy-driven walk through how SQL turns "what you want" into an execution plan, with every query shown side by side in **PostgreSQL** and **Oracle** — joins, window functions, CTEs (and CONNECT BY), ROWNUM vs FETCH FIRST pagination, indexes, and reading EXPLAIN ANALYZE against EXPLAIN PLAN.
خب، بیا رک باشیم: SQL آن زبانی است که همه فکر میکنند بلدند تا وقتی که یک پرسوجوی «درست» ناگهان روی جدول دهمیلیونردیفی کند میشود و کسی در مصاحبه میپرسد «چرا؟». تفاوت جونیور و سنیور دقیقاً همینجاست.
و یک نکته دربارهٔ این نسخه: هر تکه کد اینجا دو زبانه است — بالای هر بلوک، دو تب میبینی: PostgreSQL و Oracle. چرا مهم است؟ چون این دو موتوریاند که بیشترین احتمال دارد سرِ کار با آنها روبهرو شوی، و مصاحبهگر سنیور تقریباً همیشه یک سؤالِ «در Oracle چطور؟» در جیبش دارد. کسی که فقط یکی را بلد است، وسط مهاجرت یا وسط یک incident گیر میکند.
مثالهای PostgreSQL بر پایهٔ PostgreSQL 16/17 و مثالهای Oracle بر پایهٔ Oracle Database 19c / 23ai هستند.
قدمبهقدم میسازیم: (۱) SQL «اعلامی» است و اجرا یک «پلن» است، (۲) ترتیب منطقی اجرا، (۳) DDL/DML/DQL بههمراه تفاوت نوعداده، تاریخ و upsert در دو موتور، (۴) انواع Join و الگوریتمهای فیزیکی، (۵) GROUP BY/HAVING و STRING_AGG در برابر LISTAGG، (۶) CTE، CTE بازگشتی و میراث CONNECT BY، (۷) window functionها و analytic functionهای Oracle مثل KEEP DENSE_RANK، (۸) عملگرهای مجموعهای (EXCEPT در برابر MINUS)، (۹) صفحهبندی و سهگانهٔ ROWNUM/ROW_NUMBER()/FETCH FIRST، (۱۰) NULL و تلهٔ «رشتهٔ خالی = NULL» در Oracle، (۱۱) ایندکسها، (۱۲) EXPLAIN ANALYZE در برابر EXPLAIN PLAN/AUTOTRACE، (۱۳) سه مسئلهٔ کلاسیک، و در پایان جدول تطبیق دو موتور و ۱۸ سؤال مصاحبه.
بخش ۰ — واژههایی که باید بشناسی
- predicate (شرط): هر عبارتی که به «درست/غلط/نامعلوم» ارزیابی میشود؛ مثل
salary > 8000. مثل نگهبان دمِ در که هر ردیف را ورانداز میکند. - set (مجموعه): SQL روی «مجموعهای از ردیفها» کار میکند، نه یک ردیف در هر بار — مثل کار با کل سبد سیب بهجای تکتک سیبها.
- planner / optimizer: مغزی که تصمیم میگیرد پرسوجو چطور اجرا شود. در PostgreSQL planner، در Oracle CBO (Cost-Based Optimizer).
- statistics (آمار): خلاصهآماری که موتور از هر جدول نگه میدارد و بر پایهاش حدس میزند. در PostgreSQL با
ANALYZE، در Oracle باDBMS_STATS. - heap: در PostgreSQL فایلِ جدول روی دیسک. معادلش در Oracle segment است و آدرس فیزیکی هر ردیف را ROWID مینامند (در PostgreSQL
ctid). - DUAL: جدول تکردیفیِ سیستمیِ Oracle. چون در Oracle (تا پیش از 23ai) هر
SELECTبایدFROMداشته باشد.
هر بار واژهٔ ناآشنایی دیدی، فوراً یک تصویر واقعی برایش بساز. مغز آدم تصویر را نگه میدارد، تعریف خشک را فراموش میکند.
-- PostgreSQL: FROM اختیاری است
SELECT 1 + 1 AS two, current_date AS today;-- Oracle تا 19c: هر SELECT به FROM نیاز دارد؛ DUAL همان جدول تکردیفیِ ساختگی است
SELECT 1 + 1 AS two, TRUNC(SYSDATE) AS today FROM dual;
-- Oracle 23ai به بعد: FROM دیگر اجباری نیست --> SELECT 1 + 1 AS two;یک جدول واقعیِ متعلق به SYS با دقیقاً یک ردیف و یک ستون (DUMMY VARCHAR2(1)). وجودش صرفاً بهخاطر اجباریبودن FROM در گرامر Oracle بود. 23ai این اجبار را برداشت، ولی صدها هزار خط کد قدیمی همچنان FROM dual دارند — پس باید بشناسیاش.
SQL اعلامی است، اجرا یک پلن است
وارد رستوران میشوی و میگویی «یک استیک مدیوم». نمیگویی «اجاق را روشن کن، ماهیتابه را داغ کن». تو فقط چه چیزی را میگویی؛ آشپز تصمیم میگیرد چگونه. SQL دقیقاً همین است، و کل بازی در همین شکاف است.
هر پرسوجو به درختی از عملگرهای فیزیکی کامپایل میشود که هرکدام هزینهای تخمینی دارند. اسم این عملگرها در دو موتور فرق دارد — و همین جدول کوچک نصفِ کارِ خواندن پلن را انجام میدهد:
| مفهوم | PostgreSQL | Oracle |
|---|---|---|
| خواندن کل جدول | Seq Scan |
TABLE ACCESS FULL |
| پیمایش بازهای روی ایندکس | Index Scan |
INDEX RANGE SCAN |
| پاسخ کامل از خودِ ایندکس | Index Only Scan |
range scan بدون مرحلهٔ ROWID |
| رفتن از ایندکس به جدول | بخشی از Index Scan | TABLE ACCESS BY INDEX ROWID |
| joinها | Hash Join · Nested Loop · Merge Join |
HASH JOIN · NESTED LOOPS · SORT MERGE JOIN |
| تجمیع گروهی | HashAggregate · GroupAggregate |
HASH GROUP BY · SORT GROUP BY |
تسلط بر SQL یعنی جابهجایی روان بین دو دیدگاه: مدل منطقی (نتیجه چه معنایی دارد) و مدل فیزیکی (پایگاهداده چه میکند). جونیور در دیدگاه اول متوقف میشود؛ سنیور هر دو را کنار هم نگه میدارد و میتواند توضیح دهد چرا یک پرسوجوی «درست» کند است.
مسیر یک متن SQL تا نتیجه / The path a SQL text takes to a result:
flowchart LR
A["SQL text"] --> B["Parse"]
B --> C["Rewrite"]
C --> D["Optimizer<br/>PG: planner · Oracle: CBO"]
E["Statistics<br/>ANALYZE · DBMS_STATS"] --> D
D --> F["Execution plan"]
F --> G["Executor"]
G --> H["Result set"]
ترتیب منطقی ارزیابی
SQL آنطور که مینویسی از بالا به پایین اجرا نمیشود. تو SELECT را اول مینویسی، اما موتور تقریباً آخر آن را اجرا میکند:
FROM / JOIN -> ساخت مجموعهٔ کاری ردیفها
WHERE -> فیلتر ردیفها (aggregate مجاز نیست)
GROUP BY -> فروپاشی به گروهها
HAVING -> فیلتر گروهها (aggregate مجاز است)
SELECT -> محاسبهٔ عبارتها و window functionها
DISTINCT · ORDER BY
LIMIT/OFFSET (PostgreSQL) | OFFSET … FETCH FIRST (ANSI و Oracle)
مثل خط تولید کارخانه: مواد خام از انبار میآید (FROM)، نگهبان کیفیت ردیفهای خراب را کنار میگذارد (WHERE)، محصولات دستهبندی میشوند (GROUP BY)، دستههای ناقص حذف میشوند (HAVING)، و تازه در آخر برچسب میخورد (SELECT).
این ترتیب، اشتباهات کلاسیک را توضیح میدهد: نمیتوانی alias بندِ SELECT را در WHERE بیاوری (هنوز وجود ندارد)؛ نمیتوانی AVG(salary) را در WHERE فیلتر کنی (گروهبندی هنوز نشده — HAVING برای همین است)؛ و window functionها پس از GROUP BY اجرا میشوند، پس aggregate داخل window مجاز است ولی برعکسش نه.
«چرا WHERE avg_sal > 5000 خطا میدهد؟» چون در لحظهٔ اجرای WHERE هنوز هیچ گروهی وجود ندارد. کد خطا را هم بلد باش: PostgreSQL 42803 (grouping_error) و Oracle ORA-00934: group function is not allowed here.
PostgreSQL همیشه اجازه داده alias بندِ SELECT را در GROUP BY/ORDER BY بیاوری (ولی نه در WHERE). Oracle تا 21c ORA-00904: invalid identifier میداد؛ از 23ai alias در GROUP BY و HAVING مجاز شد و شمارهٔ ستون هم (با group_by_position_enabled = TRUE) پذیرفته میشود. اگر کدت باید روی 19c بچرخد، عبارت کامل را تکرار کن.
DDL / DML / DQL — سه زیرزبان
- DDL:
CREATE،ALTER،DROP،TRUNCATE— شکلدادن به شِما؛ معمارِ ساختمان. - DML:
INSERT،UPDATE،DELETE،MERGE— جابهجایی محتوای داخل ساختمان. - DQL:
SELECT— فقط نگاه میکند و گزارش میدهد.
در PostgreSQL، DDL تراکنشی است: BEGIN; ALTER TABLE …; CREATE INDEX …; ROLLBACK; انگار هیچ اتفاقی نیفتاده. در Oracle، هر DDL یک commit ضمنی قبل و بعد از خودش میزند؛ یعنی نهتنها خودش برگشتناپذیر است، بلکه هر DML commitنشدهٔ قبلی را هم بهزور نهایی میکند. این یکی از بزرگترین شوکهای مهاجرت است: روی Oracle باید خودت اسکریپت برگشت بنویسی.
شِمایی که در کل فصل استفاده میکنیم:
CREATE TABLE department (
id INT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE employee (
id INT PRIMARY KEY,
name TEXT NOT NULL,
dept_id INT REFERENCES department(id),
salary NUMERIC(10,2) NOT NULL,
hired_on DATE NOT NULL
);CREATE TABLE department (
id NUMBER(10) PRIMARY KEY,
name VARCHAR2(100) NOT NULL
);
CREATE TABLE employee (
id NUMBER(10) PRIMARY KEY,
name VARCHAR2(100) NOT NULL,
dept_id NUMBER(10) REFERENCES department(id),
salary NUMBER(10,2) NOT NULL,
hired_on DATE NOT NULL
);TEXT در PostgreSQL طول نامحدود دارد و هیچ جریمهٔ کارایی ندارد؛ در Oracle باید VARCHAR2(n) بنویسی (تا ۴۰۰۰ بایت، یا ۳۲۷۶۷ با MAX_STRING_SIZE = EXTENDED) و برای متن بلندتر CLOB. NUMERIC(p,s) و NUMBER(p,s) عملاً یکیاند. و مهمترینش: DATE در PostgreSQL فقط تاریخ است، ولی DATE در Oracle تاریخ بهعلاوهٔ ساعت است — چند خط پایینتر کامل بازش میکنم.
دادهٔ نمونه. دقت کن Oracle نحو چندردیفیِ VALUES (…),(…) را تا پیش از 23ai ندارد:
INSERT INTO department VALUES (1,'Engineering'),(2,'Sales'),(3,'Legal');
INSERT INTO employee VALUES
(1,'Ada', 1, 9000, DATE '2020-01-10'),
(2,'Linus', 1, 9000, DATE '2021-03-01'),
(3,'Grace', 1, 8000, DATE '2019-07-15'),
(4,'Sam', 2, 7000, DATE '2022-02-20'),
(5,'Kai', 2, 7500, DATE '2023-05-05'),
(6,'Uma', NULL, 6000, DATE '2023-06-01'); -- هنوز دپارتمانی نداردINSERT INTO department (id, name) VALUES (1,'Engineering');
INSERT INTO department (id, name) VALUES (2,'Sales');
INSERT INTO department (id, name) VALUES (3,'Legal');
-- تا 19c: چند ردیف با INSERT ALL … SELECT * FROM dual
INSERT ALL
INTO employee VALUES (1,'Ada', 1, 9000, DATE '2020-01-10')
INTO employee VALUES (2,'Linus', 1, 9000, DATE '2021-03-01')
INTO employee VALUES (3,'Grace', 1, 8000, DATE '2019-07-15')
INTO employee VALUES (4,'Sam', 2, 7000, DATE '2022-02-20')
INTO employee VALUES (5,'Kai', 2, 7500, DATE '2023-05-05')
INTO employee VALUES (6,'Uma', NULL, 6000, DATE '2023-06-01')
SELECT * FROM dual;
COMMIT; -- بدون این، هیچ session دیگری داده را نمیبینددر psql پیشفرض autocommit است؛ در SQL*Plus/SQLcl/JDBC روی Oracle، تراکنش با اولین DML باز میشود و تا COMMIT صریح باز میماند. بارها تیمی نصف روز دنبال «دادهای که ذخیره نمیشود» گشته و مشکل یک COMMIT فراموششده بوده.
دقت کن به Uma: دپارتمانش NULL است. این «کارمند بدون دپارتمان» بارها به دادمان میرسد. دپارتمان Legal هم هیچ کارمندی ندارد — این هم عمدی است.
بهروزرسانی یک جدول بر اساس جدول دیگر
اینجا دو موتور واقعاً از هم جدا میشوند:
-- بند غیراستاندارد ولی فوقالعاده راحتِ PostgreSQL
UPDATE employee e
SET salary = salary * 1.10
FROM department d
WHERE e.dept_id = d.id AND d.name = 'Engineering';-- Oracle نحو UPDATE … FROM ندارد. دو راه اصطلاحی:
UPDATE employee e
SET e.salary = e.salary * 1.10
WHERE EXISTS (SELECT 1 FROM department d
WHERE d.id = e.dept_id AND d.name = 'Engineering');
MERGE INTO employee e
USING (SELECT id FROM department WHERE name = 'Engineering') d
ON (e.dept_id = d.id)
WHEN MATCHED THEN UPDATE SET e.salary = e.salary * 1.10;
COMMIT;اگر FROM بیش از یک ردیف منطبق تولید کند، PostgreSQL بدون خطا یکی را دلخواهانه انتخاب میکند — نتیجه غیرقطعی و بعداً بهسختی قابلردیابی. MERGE در همان وضعیت خطا میدهد (ORA-30926: unable to get a stable set of rows in the source tables) که رفتار امنتری است. برای منطق حساس، MERGE را ترجیح بده.
idempotent بودن و upsert
idempotent یعنی عملیاتی که یک بار یا صد بار اجرایش کنی، نتیجه یکی است — مثل کلید چراغی که میگویی «روشن باش».
-- راه بومی PostgreSQL: ON CONFLICT (همان upsert)
INSERT INTO department (id, name) VALUES (3, 'Legal & Compliance')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
-- از PostgreSQL 15 به بعد MERGE استاندارد هم هست
MERGE INTO department d
USING (SELECT 3 AS id, 'Legal & Compliance' AS name) s
ON (d.id = s.id)
WHEN MATCHED THEN UPDATE SET name = s.name
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name);-- Oracle چیزی به نام ON CONFLICT ندارد — MERGE معادل آن است (از 9i)
MERGE INTO department d
USING (SELECT 3 AS id, 'Legal & Compliance' AS name FROM dual) s
ON (d.id = s.id)
WHEN MATCHED THEN UPDATE SET d.name = s.name
WHEN NOT MATCHED THEN INSERT (d.id, d.name) VALUES (s.id, s.name);
COMMIT;ON CONFLICT روی یک قید یکتا عمل میکند و در برابر همزمانی رفتار مشخص و امنی دارد. MERGE روی یک شرط join عمل میکند، میتواند همزمان UPDATE/INSERT/DELETE داشته باشد و منبعش یک پرسوجوی کامل باشد — پس برای بارگذاری دستهای قویتر است، ولی ذاتاً اتمیک-در-برابر-همزمانی نیست. در PostgreSQL 17، MERGE به RETURNING و WHEN NOT MATCHED BY SOURCE هم مجهز شد.
تلهٔ DATE در Oracle
DATE در Oracle همیشه ساعت هم دارد (تا دقت ثانیه). یعنی ردیفی که «۵ مه ۲۰۲۳ ساعت ۱۴:۳۰» ذخیره شده، با hired_on = DATE '2023-05-05' پیدا نمیشود.
SELECT current_date AS today,
now() AS ts_now,
to_char(hired_on, 'YYYY-MM-DD') AS hired_txt,
hired_on + INTERVAL '90 days' AS probation_end
FROM employee
WHERE hired_on >= DATE '2022-01-01'
AND hired_on < DATE '2023-01-01';SELECT TRUNC(SYSDATE) AS today,
SYSTIMESTAMP AS ts_now,
TO_CHAR(hired_on, 'YYYY-MM-DD') AS hired_txt,
hired_on + 90 AS probation_end -- عدد یعنی «روز»
FROM employee
WHERE hired_on >= DATE '2022-01-01'
AND hired_on < DATE '2023-01-01';راهِ وسوسهکننده این است که بنویسی WHERE TRUNC(hired_on) = DATE '2023-05-05'. جواب درست میدهد ولی ایندکس را از کار میاندازد (دیگر sargable نیست) و به TABLE ACCESS FULL میرسی. راه درست همیشه بازهٔ نیمباز است: >= DATE '2023-05-05' AND < DATE '2023-05-06'. اگر واقعاً مجبوری، یک function-based index روی TRUNC(hired_on) بساز.
SYSDATE ساعت سرور را میدهد و به تراکنش گره نخورده. در PostgreSQL، now() و CURRENT_TIMESTAMP ساعتِ شروع تراکنش را میدهند (داخل یک تراکنش ثابت میمانند) و برای ساعت لحظهای باید clock_timestamp() بنویسی. این تفاوت در گزارشهای زماندار و تستها واقعاً گاز میگیرد.
NVL / DECODE در برابر COALESCE / CASE
Oracle پیش از استانداردشدن ANSI، توابع خودش را داشت. هر دو خانواده امروز در Oracle کار میکنند، ولی کد قدیمی پر است از NVL و DECODE:
SELECT name,
COALESCE(dept_id::text, 'unassigned') AS dept,
CASE WHEN salary > 8000 THEN 'senior'
WHEN salary > 7000 THEN 'mid'
ELSE 'junior' END AS band,
NULLIF(salary, 0) AS salary_or_null
FROM employee;SELECT name,
NVL(TO_CHAR(dept_id), 'unassigned') AS dept,
CASE WHEN salary > 8000 THEN 'senior'
WHEN salary > 7000 THEN 'mid'
ELSE 'junior' END AS band,
NULLIF(salary, 0) AS salary_or_null,
DECODE(dept_id, 1, 'Eng', 2, 'Sales', 'Other') AS dept_short, -- سبک قدیمی
NVL2(dept_id, 'assigned', 'free') AS assignment_state
FROM employee;COALESCE هر تعداد آرگومان میگیرد و کوتاهمدار است: بهمحض یافتن اولین مقدار غیرِNULL، بقیه را ارزیابی نمیکند. NVL دقیقاً دو آرگومان میگیرد و هر دو را ارزیابی میکند — پس اگر آرگومان دوم یک subquery سنگین باشد، هزینهاش را بیدلیل میپردازی. ضمناً NVL نوع آرگومان دوم را به نوع اولی cast میکند و میتواند ORA-01722: invalid number بدهد. قاعدهٔ عملی: در کد جدیدِ Oracle هم COALESCE و CASE بنویس.
ستونهای خودافزا و RETURNING
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
msg TEXT NOT NULL,
logged_at timestamptz NOT NULL DEFAULT now()
);
-- RETURNING در PostgreSQL یک result set واقعی برمیگرداند
INSERT INTO audit_log (msg) VALUES ('raise applied')
RETURNING id, logged_at;CREATE TABLE audit_log (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- از 12c
msg VARCHAR2(4000) NOT NULL,
logged_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
-- Oracle هم RETURNING دارد، اما فقط RETURNING … INTO (متغیر bind)
DECLARE
v_id NUMBER;
BEGIN
INSERT INTO audit_log (msg) VALUES ('raise applied') RETURNING id INTO v_id;
COMMIT;
END;
/الگوی کلاسیک: یک SEQUENCE بساز و در یک BEFORE INSERT trigger مقدارش را بگذار — اگر روی کد قدیمی کار میکنی این الگو را زیاد میبینی. در PostgreSQL هم serial نسخهٔ قدیمی و GENERATED … AS IDENTITY نسخهٔ استاندارد است.
Joinها — همهٔ انواع، همراه با ریاضیِ ردیفها
دو دفترچهٔ تلفن داری: یکی کارمندان با شمارهٔ دپارتمانشان، یکی دپارتمانها با اسمشان. Join یعنی این دو را کنار هم بگذاری. شرطی که میگوید کدام ردیفِ این با کدام ردیفِ آن جفت میشود، همان predicate است.
مدل ذهنیای که هرگز شکست نمیخورد: INNER اشتراکِ منطبق را میدهد؛ OUTER ردیفهای نامنطبق یک یا هر دو طرف را بازمیگرداند و طرفِ گمشده را با NULL پر میکند.
INNER JOIN LEFT JOIN FULL JOIN
A ∩ B A + (A∩B) A ∪ B
┌───┐ ┌───────┐ ┌───────────┐
│▓▓▓│ │███▓▓▓ │ │███▓▓▓███ │
└───┘ └───────┘ └───────────┘
فقط منطبقها همهٔ A، منطبقهای B همهچیز، پرشده با NULL
INNER JOIN یعنی فقط کسانی وارد میشوند که هم در لیست دعوت باشند و هم واقعاً بیایند. LEFT JOIN یعنی «همهٔ کسانی که آمدند وارد شوند، و برای آنهایی که در لیست نیستند بنویسیم دپارتمان: نامعلوم (NULL)». FULL JOIN یعنی هم مهمانهای بیلیست و هم دعوتشدگانی که نیامدند، همه در گزارش بیایند.
خبر خوب: نحو ANSI برای join در هر دو موتور دقیقاً یکسان است. یادت باشد Uma دپارتمان ندارد و Legal کارمند ندارد:
-- INNER: کارمندانی که دپارتمان دارند (Uma حذف میشود)
SELECT e.name, d.name AS dept FROM employee e
JOIN department d ON e.dept_id = d.id;
-- LEFT: همهٔ کارمندان؛ Uma با dept = NULL ظاهر میشود
SELECT e.name, d.name AS dept FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;
-- RIGHT: همهٔ دپارتمانها؛ Legal با employee = NULL ظاهر میشود
SELECT e.name, d.name AS dept FROM employee e
RIGHT JOIN department d ON e.dept_id = d.id;
-- FULL: اجتماع هر دو
SELECT e.name, d.name AS dept FROM employee e
FULL JOIN department d ON e.dept_id = d.id;-- نحو ANSI در Oracle از 9i کاملاً پشتیبانی و توصیه میشود
SELECT e.name, d.name AS dept FROM employee e
JOIN department d ON e.dept_id = d.id;
SELECT e.name, d.name AS dept FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;
SELECT e.name, d.name AS dept FROM employee e
RIGHT JOIN department d ON e.dept_id = d.id;
SELECT e.name, d.name AS dept FROM employee e
FULL JOIN department d ON e.dept_id = d.id;نحو قدیمیِ (+) در Oracle
اگر روی یک سیستم Oracle قدیمی کار کنی حتماً به این میخوری. Oracle پیش از پشتیبانی ANSI، عملگر (+) را داشت که کنارِ ستونِ طرفی که NULL تولید میکند میآید:
-- PostgreSQL عملگر (+) ندارد؛ تنها شکل ممکن همان ANSI است
SELECT e.name, d.name AS dept FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;SELECT e.name, d.name AS dept
FROM employee e, department d
WHERE e.dept_id = d.id(+); -- معادل LEFT JOIN
SELECT e.name, d.name AS dept
FROM employee e, department d
WHERE e.dept_id(+) = d.id; -- معادل RIGHT JOINسه دلیل: (۱) (+) نمیتواند FULL OUTER JOIN بسازد — اگر هر دو طرف را علامت بزنی ORA-01468 میگیری. (۲) اگر روی یکی از شرطهای مربوط به آن جدول (+) را جا بیندازی، بیصدا به INNER JOIN تبدیل میشود. (۳) خودِ Oracle در مستنداتش نحو ANSI را توصیه میکند چون بهینهساز روی آن امکانات بیشتری دارد.
CROSS JOIN و تلهٔ ON در برابر WHERE
CROSS JOIN حاصلضرب دکارتی است — همهٔ ترکیبهای ممکن (۶ کارمند × ۳ دپارتمان = ۱۸ ردیف). برای تولید شبکه یا تقویم مفید است، نه برای join معمولی.
حالا تلهٔ کلاسیک: در یک LEFT JOIN، شرط در ON پیش از join فیلتر میکند؛ همان شرط در WHERE پس از join فیلتر میکند و بیصدا join بیرونی را به داخلی تبدیل میکند.
-- غلط: مثل INNER JOIN رفتار میکند، چون NULL = 'Sales' برابر UNKNOWN است
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id
WHERE d.name = 'Sales';
-- درست: با گذاشتن شرط در ON، معنای LEFT حفظ میشود
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id AND d.name = 'Sales';-- دقیقاً همان تله و همان رفتار در Oracle
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id
WHERE d.name = 'Sales'; -- بیصدا INNER شد
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id AND d.name = 'Sales';
-- و در نحو قدیمی: هر شرطِ مربوط به d باید (+) بگیرد
SELECT e.name, d.name
FROM employee e, department d
WHERE e.dept_id = d.id(+) AND d.name(+) = 'Sales';چرا؟ LEFT JOIN اول همهٔ کارمندان را نگه میدارد و d.name را برای بیدپارتمانها NULL میگذارد. بعد WHERE d.name = 'Sales' میآید؛ ولی NULL = 'Sales' نه true است نه false، بلکه UNKNOWN — و WHERE هرچه UNKNOWN باشد را دور میریزد. نتیجه: یک INNER JOIN پنهان.
هر شرطی روی جدولِ سمتِ nullable که در WHERE بگذاری، LEFT JOIN را به INNER تنزل میدهد. تنها استثنا WHERE d.id IS NULL است که عمداً برای anti-join نوشته میشود.
self-join و anti-join
self-join یعنی جدول را به خودش join کنی — وقتی رابطه داخل خودِ جدول است (کارمند → مدیر). anti-join یعنی پیدا کردن ردیفهایی که هیچ تطابقی ندارند:
-- دپارتمانهای بدون کارمند
SELECT d.name FROM department d
WHERE NOT EXISTS (SELECT 1 FROM employee e WHERE e.dept_id = d.id);
-- شکل جایگزین: LEFT JOIN + IS NULL (پلن معمولاً یکسان درمیآید)
SELECT d.name FROM department d
LEFT JOIN employee e ON e.dept_id = d.id
WHERE e.id IS NULL;SELECT d.name FROM department d
WHERE NOT EXISTS (SELECT 1 FROM employee e WHERE e.dept_id = d.id);
SELECT d.name FROM department d
LEFT JOIN employee e ON e.dept_id = d.id
WHERE e.id IS NULL;
-- Oracle یک ابزار اضافه هم دارد: MINUS (معادل EXCEPT)
SELECT id FROM department
MINUS
SELECT dept_id FROM employee WHERE dept_id IS NOT NULL;اگر زیرپرسوجو حتی یک NULL تولید کند، col NOT IN (…) برای همهٔ ردیفها به UNKNOWN فرومیپاشد و نتیجه صفر ردیف میشود — در PostgreSQL و Oracle یکسان. راهحل قطعی: NOT EXISTS. اگر مجبور به NOT IN هستی، WHERE x IS NOT NULL را داخل زیرپرسوجو بگذار.
الگوریتمهای join
| الگوریتم | نحوهٔ کار | بهترین حالت | PostgreSQL / Oracle |
|---|---|---|---|
| حلقهٔ تودرتو | برای هر ردیف بیرونی، درونی را کاوش کن | یک طرف کوچک، درونی ایندکسدار | Nested Loop / NESTED LOOPS |
| درهمسازی | روی طرف کوچکتر hash بساز، با بزرگتر کاوش کن | join تساوی روی دادهٔ بزرگ و نامرتب | Hash Join / HASH JOIN |
| ادغام مرتب | هر دو ورودی را مرتب کن و همگام پیش برو | هر دو از پیش مرتب/ایندکسشده | Merge Join / SORT MERGE JOIN |
برای هر اسم در یک لیست میخواهی شمارهاش را از دفترچه پیدا کنی. حلقهٔ تودرتو: برای هر اسم دفترچه را میگردی. درهمسازی: اول یک برگهٔ کمکیِ اسم→شماره میسازی، بعد لیستت را یکبار میخوانی. ادغام مرتب: اگر هر دو الفبایی مرتب باشند، دو انگشتت را روی هرکدام میگذاری و همزمان پایین میآیی.
و اگر خواستی برنامهریز را مجبور کنی راه دیگری برود، دو فلسفهٔ کاملاً متفاوت داری:
-- PostgreSQL hint داخلی ندارد؛ فلسفهاش «بهینهساز را درست کن، نه پلن را» است
SET enable_hashjoin = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.name, d.name FROM employee e JOIN department d ON d.id = e.dept_id;
RESET enable_hashjoin;
-- (افزونهٔ pg_hint_plan اگر نصب باشد، hintهایی به سبک Oracle اضافه میکند)-- Oracle سیستم hint کامل و مستند دارد
SELECT /*+ LEADING(d e) USE_HASH(e) FULL(e) */ e.name, d.name
FROM employee e JOIN department d ON d.id = e.dept_id;
SELECT /*+ INDEX(e ix_emp_dept_sal) */ e.name, e.salary
FROM employee e WHERE e.dept_id = 1;hint یک مسکّن است نه درمان. در Oracle، hint در پلن «قفل» میشود و وقتی حجم داده یا آمار عوض شد، خودش تبدیل به علتِ کندی میشود. راهحل پایدارتر SQL Plan Baseline (با DBMS_SPM) است که پلن خوب را تثبیت میکند ولی اجازه میدهد پلن بهترِ بعدی تأیید و جایگزین شود. در PostgreSQL، کار درست این است که آمار را درست کنی (ANALYZE، CREATE STATISTICS) نه اینکه پلن را زور کنی.
تجمیع: GROUP BY و HAVING
گاهی جزئیات نمیخواهی، خلاصه میخواهی: «میانگین حقوق هر دپارتمان چقدر است؟». GROUP BY چند ردیف را به یک ردیفِ خلاصه فرومیپاشد. تفاوت کلیدی: HAVING گروهها را فیلتر میکند؛ WHERE ردیفها را پیش از گروهبندی.
SELECT d.name,
COUNT(*) AS headcount,
AVG(e.salary) AS avg_salary,
MAX(e.salary) AS top_salary
FROM employee e JOIN department d ON d.id = e.dept_id
WHERE e.hired_on >= DATE '2021-01-01' -- اول ردیفها فیلتر میشوند
GROUP BY d.name
HAVING AVG(e.salary) > 7500; -- سپس گروههاSELECT d.name,
COUNT(*) AS headcount,
AVG(e.salary) AS avg_salary,
MAX(e.salary) AS top_salary
FROM employee e JOIN department d ON d.id = e.dept_id
WHERE e.hired_on >= DATE '2021-01-01'
GROUP BY d.name
HAVING AVG(e.salary) > 7500;نکات ظریفی که در مصاحبه دقیقاً همینها را میپرسند: COUNT(*) همهٔ ردیفها را میشمارد، COUNT(col) مقادیر NULL را رد میکند، COUNT(DISTINCT col) مقادیر متمایز را. هر ستون غیرتجمیعیِ SELECT باید در GROUP BY بیاید — تخلف در PostgreSQL خطای 42803 و در Oracle ORA-00979: not a GROUP BY expression میدهد. و GROUPING SETS/ROLLUP/CUBE در یک پیمایش جمعهای جزئی و کلی تولید میکنند — هر دو موتور همهشان را دارند.
هرچه زودتر ردیف بریزی دور، بهتر. WHERE پیش از GROUP BY اجرا میشود، پس هر شرطی که به aggregate نیاز ندارد را آنجا بگذار. HAVING را فقط برای شرطهایی نگهدار که واقعاً به مقدار تجمیعی نیاز دارند.
تجمیع رشتهای: STRING_AGG در برابر LISTAGG
اسم این تابع در دو موتور فرق دارد و یکی از پرتکرارترین سؤالهای «تفاوت دو موتور» است:
SELECT d.name AS dept,
COUNT(*) AS headcount,
STRING_AGG(e.name, ', ' ORDER BY e.salary DESC) AS members
FROM employee e JOIN department d ON d.id = e.dept_id
GROUP BY d.name;SELECT d.name AS dept,
COUNT(*) AS headcount,
-- ترتیب با WITHIN GROUP اجباری است، و ON OVERFLOW جلوی ORA-01489 را میگیرد
LISTAGG(e.name, ', ' ON OVERFLOW TRUNCATE '…' WITH COUNT)
WITHIN GROUP (ORDER BY e.salary DESC) AS members
FROM employee e JOIN department d ON d.id = e.dept_id
GROUP BY d.name;خروجی LISTAGG یک VARCHAR2 است با سقف ۴۰۰۰ بایت (یا ۳۲۷۶۷ با MAX_STRING_SIZE = EXTENDED). اگر گروهی بزرگ شود، در محیط تولید ناگهان ORA-01489: result of string concatenation is too long میگیری — و بدترین بخشش این است که روی دادهٔ تست هرگز رخ نمیدهد. بند ON OVERFLOW TRUNCATE (از 12.2) این را رام میکند. STRING_AGG در PostgreSQL چنین سقفی ندارد (نوعش text است). ضمناً DISTINCT داخل LISTAGG تازه از 19c مجاز شد، در حالی که PostgreSQL همیشه داشته است.
تجمیع شرطی: FILTER در برابر CASE
-- PostgreSQL بند استاندارد FILTER را دارد؛ خواناترین شکل ممکن
SELECT dept_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE salary > 8000) AS high_earners,
SUM(salary) FILTER (WHERE hired_on >= DATE '2022-01-01') AS recent_payroll
FROM employee GROUP BY dept_id;-- Oracle بند FILTER ندارد — CASE داخل aggregate معادل دقیق آن است
SELECT dept_id,
COUNT(*) AS total,
COUNT(CASE WHEN salary > 8000 THEN 1 END) AS high_earners,
SUM(CASE WHEN hired_on >= DATE '2022-01-01'
THEN salary END) AS recent_payroll
FROM employee GROUP BY dept_id;چون COUNT مقادیر NULL را نمیشمارد. اگر ELSE 0 بگذاری، صفرها هم شمرده میشوند و جواب غلط میشود. بدون ELSE، مقدارِ پیشفرضِ CASE برابر NULL است و دقیقاً همان ردیفها را رد میکند که میخواستی. این الگو در هر دو موتور کار میکند و پایهٔ «تجمیع محوری (pivot)» است.
زیرپرسوجوها در برابر CTEها
یک subquery یک SELECT تودرتو است؛ کاربردی، اما وقتی زیاد تودرتو شود خواندنش مثل باز کردن پیازِ چندلایه میشود. یک CTE (بند WITH) یک زیرپرسوجوی نامدار است که ابتدای دستور اعلامش میکنی — مثل نوشتن «سسِ پایه = فلان» در بالای دستور آشپزی. خواناتر است، قابل استفادهٔ مجدد در همان دستور است، و راهِ استانداردِ بازگشت است. در Oracle به آن subquery factoring میگویند و از 9i وجود دارد:
WITH dept_stats AS (
SELECT dept_id, AVG(salary) AS avg_sal FROM employee GROUP BY dept_id
)
SELECT e.name, e.salary, s.avg_sal
FROM employee e
JOIN dept_stats s ON s.dept_id = e.dept_id
WHERE e.salary > s.avg_sal; -- حقوق بالاتر از میانگین دپارتمان خودش-- در Oracle به این «subquery factoring» میگویند؛ نحو یکسان است
WITH dept_stats AS (
SELECT dept_id, AVG(salary) AS avg_sal FROM employee GROUP BY dept_id
)
SELECT e.name, e.salary, s.avg_sal
FROM employee e
JOIN dept_stats s ON s.dept_id = e.dept_id
WHERE e.salary > s.avg_sal;materialize شدن CTE
دو واژه اول: materialize کردن یعنی نتیجهٔ یک زیرپرسوجو را یکبار کامل حساب و در جدولی موقت ذخیره کنی (مثل پختن کل سسِ پایه از قبل). optimization fence یعنی دیواری که نمیگذارد برنامهریز شرطهای بیرونی را به داخل زیرپرسوجو «هل بدهد» (به این هل دادن میگویند predicate push-down).
پیش از PostgreSQL 12 یک CTE همیشه یک سدِ بیقید بود. از ۱۲ به بعد، CTEای که فقط یکبار ارجاع میشود و اثر جانبی ندارد پیشفرض inline میشود. Oracle هرگز چنین قاعدهٔ نحویای نداشت — CBO هزینهمحور تصمیم میگیرد و با دو hint هدایت میشود:
WITH recent AS MATERIALIZED ( -- اعمال اجباری سد
SELECT * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;
-- و برعکس: NOT MATERIALIZED حتی برای CTEهای چندبار ارجاعشده inline را اجبار میکند
WITH recent AS NOT MATERIALIZED (
SELECT * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;-- Oracle کلیدواژهٔ MATERIALIZED ندارد؛ دو hint همان کار را میکنند
WITH recent AS (
SELECT /*+ MATERIALIZE */ * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;
WITH recent AS (
SELECT /*+ INLINE */ * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;این دو hint در مستندات رسمی Oracle فهرست نشدهاند ولی از دههٔ ۹۰ کار میکنند و در عمل استاندارد صنعتیاند. وقتی Oracle یک CTE را materialize میکند، عملاً یک global temporary table داخلی میسازد و در پلن میبینی: TEMP TABLE TRANSFORMATION و LOAD AS SELECT.
CTE بازگشتی
بعضی دادهها درختیاند: چارت سازمانی، سلسلهمراتب دستهبندی، لیستِ قطعات. یک CTE بازگشتی دو بخش دارد که با UNION ALL به هم میچسبند: anchor (لنگر) یعنی حالت پایه، و جملهٔ بازگشتی که به خودِ CTE ارجاع میدهد و هر بار یک قدم جلو میرود.
درختی که میپیماییم / The tree we are about to walk:
flowchart TD
CEO["CEO (1)"] --> CTO["CTO (2)"]
CTO --> VP["VP Eng (3)"]
VP --> A["Dev A (4)"]
VP --> B["Dev B (5)"]
CREATE TABLE emp_tree (id INT PRIMARY KEY, name TEXT, manager_id INT);
INSERT INTO emp_tree VALUES
(1,'CEO',NULL),(2,'CTO',1),(3,'VP Eng',2),(4,'Dev A',3),(5,'Dev B',3);
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS depth
FROM emp_tree WHERE id = 4 -- لنگر: شروع از Dev A
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1
FROM emp_tree e
JOIN chain c ON e.id = c.manager_id -- بالا رفتن بهسمت CEO
)
SELECT * FROM chain; -- Dev A -> VP Eng -> CTO -> CEOCREATE TABLE emp_tree (id NUMBER(10) PRIMARY KEY, name VARCHAR2(50), manager_id NUMBER(10));
INSERT ALL
INTO emp_tree VALUES (1,'CEO',NULL) INTO emp_tree VALUES (2,'CTO',1)
INTO emp_tree VALUES (3,'VP Eng',2) INTO emp_tree VALUES (4,'Dev A',3)
INTO emp_tree VALUES (5,'Dev B',3)
SELECT * FROM dual;
COMMIT;
-- Oracle کلیدواژهٔ RECURSIVE ندارد، اما لیست ستونها اجباری است
WITH chain (id, name, manager_id, depth) AS (
SELECT id, name, manager_id, 1 FROM emp_tree WHERE id = 4
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1
FROM emp_tree e JOIN chain c ON e.id = c.manager_id
)
SELECT * FROM chain;(۱) در Oracle کلیدواژهٔ RECURSIVE وجود ندارد؛ اگر بنویسیاش خطای نحوی میگیری — بازگشتیبودن از روی ارجاعِ CTE به خودش تشخیص داده میشود. (۲) در Oracle لیست ستونهای CTE برای حالت بازگشتی اجباری است؛ اگر جا بیندازی ORA-32039: recursive WITH clause must have column alias list میگیری. در PostgreSQL هر دو برعکساند.
لنگر میگوید «روی پلهٔ Dev A بایست». جملهٔ بازگشتی میگوید «یک پله بالا برو به سمت مدیرت و تکرار کن تا مدیری نمانَد». هر تکرار depth را یکی زیاد میکند تا بدانی چند پله بالا آمدهای.
UNION ALL (نه UNION) برای انباشتِ بازگشت لازم است. برای محافظت در برابر چرخه در دادهٔ کثیف، هر دو موتور بندهای استاندارد SEARCH و CYCLE را دارند — PostgreSQL از ۱۴ و Oracle از 11gR2. فقط یادت باشد PostgreSQL مینویسد CYCLE id SET is_cycle USING path (ستون boolean + ستون مسیر) و Oracle مینویسد CYCLE id SET is_cycle TO 'Y' DEFAULT 'N' (دو مقدار متنی، بدون ستون مسیر).
CONNECT BY — میراث Oracle که هنوز همهجا هست
پیش از استانداردشدن CTE بازگشتی، Oracle از دههٔ ۸۰ نحو اختصاصی خودش را داشت. این نحو در میلیونها خط کد سازمانی زنده است:
-- PostgreSQL معادل CONNECT BY ندارد؛ ستونهای مصنوعی را دستی میسازی
WITH RECURSIVE t AS (
SELECT id, name, manager_id,
1 AS lvl, -- معادل LEVEL
name AS root_name, -- معادل CONNECT_BY_ROOT
' -> ' || name AS path -- معادل SYS_CONNECT_BY_PATH
FROM emp_tree WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id,
t.lvl + 1, t.root_name, t.path || ' -> ' || e.name
FROM emp_tree e JOIN t ON e.manager_id = t.id
)
SELECT lvl, LPAD(' ', (lvl-1)*2) || name AS org, path FROM t ORDER BY path;-- نحو ارثیِ Oracle: فشردهتر، با ستونهای مصنوعیِ آماده
SELECT LEVEL AS lvl,
LPAD(' ', (LEVEL-1)*2) || name AS org,
CONNECT_BY_ROOT name AS root_name,
SYS_CONNECT_BY_PATH(name, ' -> ') AS path,
CONNECT_BY_ISLEAF AS is_leaf
FROM emp_tree
START WITH manager_id IS NULL -- لنگر
CONNECT BY NOCYCLE PRIOR id = manager_id -- گام بازگشتی؛ PRIOR یعنی «ردیف والد»
ORDER SIBLINGS BY name;START WITH ⇔ عضو لنگر · CONNECT BY PRIOR parent = child ⇔ عضو بازگشتی با شرط join · LEVEL ⇔ ستون depth که خودت با +1 میسازی · SYS_CONNECT_BY_PATH ⇔ الحاق رشتهای مسیر · CONNECT_BY_ROOT ⇔ ستونی که از لنگر بدون تغییر حمل میشود · NOCYCLE ⇔ بند CYCLE · ORDER SIBLINGS BY ⇔ SEARCH DEPTH FIRST BY … SET ord و بعد ORDER BY ord.
CONNECT BY مخصوص Oracle، بسیار فشرده، و برای پیمایشهای ساده معمولاً کمی سریعتر است چون موتور اختصاصی دارد و ستونهای آمادهای مثل LEVEL، CONNECT_BY_ISLEAF و SYS_CONNECT_BY_PATH میدهد. CTE بازگشتی استاندارد ANSI است، در PostgreSQL/SQL Server/MySQL 8+ هم کار میکند و برای منطقهای پیچیدهتر (انباشت چند ستون، تجمیع حین پیمایش) انعطاف بیشتری دارد. توصیه: در کد جدیدِ Oracle هم CTE بازگشتی بنویس مگر دقیقاً به یکی از ستونهای مصنوعی نیاز داشته باشی. Oracle از 11gR2 هر دو را دارد؛ PostgreSQL فقط CTE بازگشتی را.
Window functionها — تجمیعهایی که ردیفها را نگه میدارند
Oracle اینها را از نسخهٔ 8i (سال ۱۹۹۹) دارد و در ادبیات Oracle به آنها analytic function میگویند — پس اگر مصاحبهگر گفت «analytic function»، منظورش دقیقاً همینهاست.
مشکل GROUP BY این بود که ردیفها را فرومیپاشید. اما گاهی هر دو را میخواهی: هم جزئیات هر ردیف، هم یک محاسبهٔ گروهی کنارش.
روی قطار نشستهای و از پنجره بیرون را نگاه میکنی. «پنجره» بخشی از منظره است که در هر لحظه میبینی و با حرکت قطار جابهجا میشود. window function هم برای هر ردیف یک «پنجره» از ردیفهای مرتبط را میبیند و رویشان حساب میکند — اما خودِ آن ردیف سرِ جایش میمانَد.
نحو: func(...) OVER (PARTITION BY … ORDER BY … frame).
SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dense,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg,
ROUND(100.0 * salary / SUM(salary) OVER (PARTITION BY dept_id), 1) AS pct
FROM employee;SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dense,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg,
-- RATIO_TO_REPORT مخصوص Oracle است و همان نسبت را مستقیم میدهد
ROUND(100 * RATIO_TO_REPORT(salary) OVER (PARTITION BY dept_id), 1) AS pct
FROM employee;سهگانهٔ رتبهبندی یک تلهٔ محبوب مصاحبه است. با حقوقهای ۹۰۰۰، ۹۰۰۰، ۸۰۰۰ در یک partition:
| تابع | خروجی | رفتار در تساوی |
|---|---|---|
ROW_NUMBER |
1، 2، 3 | همیشه یکتا؛ شکستن تساوی دلخواه |
RANK |
1، 1، 3 | رتبهٔ مشترک؛ شکاف بعد از آن |
DENSE_RANK |
1، 1، 2 | رتبهٔ مشترک؛ بدون شکاف |
دو دونده همزمان اول میشوند. ROW_NUMBER مثل داوری است که مجبور است یکی را اول و یکی را دوم بنویسد. RANK مثل مسابقهٔ واقعی: هر دو طلا (رتبهٔ ۱)، ولی نفر بعدی رتبهٔ ۳. DENSE_RANK مهربانتر است: هر دو رتبهٔ ۱ و نفر بعدی رتبهٔ ۲.
LAG / LEAD و تفاوت IGNORE NULLS
SELECT name, hired_on, salary,
LAG(salary) OVER (ORDER BY hired_on) AS prev_salary,
LEAD(salary) OVER (ORDER BY hired_on) AS next_salary,
salary - LAG(salary) OVER (ORDER BY hired_on) AS delta
FROM employee;
-- توجه: PostgreSQL 17 بند IGNORE NULLS را ندارد؛ رفتار همیشه RESPECT NULLS است.SELECT name, hired_on, salary,
LAG(salary) OVER (ORDER BY hired_on) AS prev_salary,
LEAD(salary) OVER (ORDER BY hired_on) AS next_salary,
salary - LAG(salary) OVER (ORDER BY hired_on) AS delta,
-- Oracle از ابتدا IGNORE NULLS را دارد: آخرین مقدارِ غیرِNULL را میآورد
LAG(salary IGNORE NULLS) OVER (ORDER BY hired_on) AS prev_non_null
FROM employee;الگوی «آخرین مقدار شناختهشده را حمل کن» (مثلاً آخرین قیمت ثبتشده تا این تاریخ) در Oracle با LAST_VALUE(price IGNORE NULLS) یک خط است. PostgreSQL 17 این بند را ندارد و باید با ترفند بنویسیاش — مثلاً MAX(price) FILTER (WHERE price IS NOT NULL) OVER (ORDER BY d ROWS UNBOUNDED PRECEDING). اگر از Oracle به PostgreSQL مهاجرت میکنی، این یکی از پرتکرارترین بازنویسیهاست.
قابها (frames) و جمعهای دونده
«قاب» یعنی دقیقاً کدام ردیفها درون پنجرهٔ ردیف جاری حساب شوند. وجود ORDER BY در یک window پیشفرض قابِ RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW را میسازد — و این پیشفرض در هر دو موتور یکسان و همانقدر خطرناک است. ROWS ردیفهای فیزیکی را میشمارد، ولی RANGE همترازها (ردیفهایی با مقدار ORDER BY یکسان) را در یک مرز جمع میکند:
SELECT name, hired_on, salary,
SUM(salary) OVER (ORDER BY hired_on
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
AVG(salary) OVER (ORDER BY hired_on
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3,
SUM(salary) OVER (ORDER BY salary) AS range_default
FROM employee;SELECT name, hired_on, salary,
SUM(salary) OVER (ORDER BY hired_on
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
AVG(salary) OVER (ORDER BY hired_on
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3,
SUM(salary) OVER (ORDER BY salary) AS range_default
FROM employee;SUM(salary) OVER (ORDER BY salary) بدون قابِ صریح یعنی RANGE. Ada و Linus هر دو ۹۰۰۰ میگیرند، پس همتراز (peer) هستند و هر دو یک عدد یکسان میگیرند که شاملِ هر دویشان است. اگر انتظارِ جمعِ دوندهی ردیفبهردیف داشتی، جوابت غلط است. قاعده: هر وقت جمع دونده میخواهی، ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW را صریح بنویس — در هر دو موتور.
میراث تحلیلی Oracle: KEEP DENSE_RANK
Oracle یک ابزار زیبا دارد که PostgreSQL معادل مستقیمش را ندارد: «در هر گروه، مقدارِ ستون X را از ردیفی بده که ستون Y در آن بیشینه/کمینه است» — بدون هیچ subquery اضافه:
-- PostgreSQL بند KEEP ندارد. دو معادل رایج:
SELECT dept_id,
MAX(salary) AS top_salary,
(ARRAY_AGG(name ORDER BY salary DESC, id))[1] AS top_earner
FROM employee GROUP BY dept_id;
-- DISTINCT ON — مخصوص PostgreSQL، برای «یک ردیفِ برتر در هر گروه»
SELECT DISTINCT ON (dept_id) dept_id, name AS top_earner, salary
FROM employee ORDER BY dept_id, salary DESC, id;-- Oracle: FIRST/LAST با KEEP (DENSE_RANK …) — یک عبور، بدون subquery
SELECT dept_id,
MAX(salary) AS top_salary,
MAX(name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC, id) AS top_earner,
MAX(name) KEEP (DENSE_RANK LAST ORDER BY salary DESC, id) AS bottom_earner
FROM employee GROUP BY dept_id;اول اینکه در کد Oracle واقعی زیاد میبینیاش و بدون آن خواندن گزارشهای مالی غیرممکن میشود. دوم اینکه جواب درجهیکِ سؤالِ «top-1 در هر گروه را بدون window function بنویس» است. سوم اینکه معمولاً از الگویِ «subquery با ROW_NUMBER و فیلتر rn = 1» سریعتر است چون فقط یک عبور روی داده لازم دارد. MAX(name) بیرونی صرفاً برای رفع ابهام وقتی چند ردیف همرتبه باشند لازم است.
هر جا به رتبهبندی، جمع دونده، یا مقایسهٔ ردیف با ردیف نیاز داری، اول سراغ window function برو نه self-join. شفافتر است، معمولاً سریعتر (یک عبور بهجای O(n²) مقایسه)، و کمتر دچار خطای منطقی میشود.
عملگرهای مجموعهای
UNION تکراریها را حذف میکند (پیمایش distinct، پرهزینه)؛ UNION ALL نگه میدارد (ارزانتر)؛ INTERSECT اشتراک؛ و EXCEPT/MINUS تفاضل.
SELECT id FROM employee WHERE dept_id = 1
EXCEPT
SELECT id FROM employee WHERE salary < 8500; -- کارمندان Eng با حقوق >= 8500
-- PostgreSQL نسخهٔ ALL همهٔ آنها را هم دارد (حفظ تعداد تکرار)
SELECT dept_id FROM employee EXCEPT ALL SELECT id FROM department;-- نام تاریخی این عملگر در Oracle، MINUS است
SELECT id FROM employee WHERE dept_id = 1
MINUS
SELECT id FROM employee WHERE salary < 8500;
-- از Oracle 21c، EXCEPT بهعنوان مترادف MINUS اضافه شد،
-- بههمراه MINUS ALL / EXCEPT ALL / INTERSECT ALL
SELECT id FROM employee WHERE dept_id = 1
EXCEPT
SELECT id FROM employee WHERE salary < 8500;EXCEPT وجود ندارد و خطای نحوی میگیری — باید MINUS بنویسی. همچنین در 19c فقط UNION ALL نسخهٔ ALL دارد؛ MINUS ALL و INTERSECT ALL از 21c اضافه شدند. این دقیقاً همان جزئیاتی است که کد پورتشده را در محیط تولید میشکند.
صفحهبندی: ROWNUM، ROW_NUMBER() و FETCH FIRST
شاید پرتکرارترین سؤالِ عملیِ «تفاوت دو موتور» در مصاحبهها. PostgreSQL بند غیراستاندارد LIMIT … OFFSET … را دارد؛ Oracle آن را ندارد — تا 11g فقط ROWNUM را داشت و از 12c بند استاندارد ANSI به نام row-limiting clause را دارد (که PostgreSQL هم میفهمد):
SELECT id, name, salary FROM employee
ORDER BY salary DESC, id
LIMIT 10 OFFSET 20;
-- شکل استاندارد ANSI که PostgreSQL هم از ۸.۴ میفهمد (پرتابلتر است)
SELECT id, name, salary FROM employee
ORDER BY salary DESC, id
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;-- Oracle اصلاً LIMIT ندارد. از 12c به بعد شکل استاندارد:
SELECT id, name, salary FROM employee
ORDER BY salary DESC, id
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
-- و اگر همرتبههای ردیف آخر هم باید بیایند (هر دو موتور دارند):
SELECT id, name, salary FROM employee
ORDER BY salary DESC
FETCH FIRST 3 ROWS WITH TIES;ROWNUM و مادرِ همهٔ تلهها
ROWNUM یک شبهستون Oracle است که در لحظهٔ عبور ردیف از فیلتر مقدار میگیرد — یعنی پیش از ORDER BY:
-- PostgreSQL شبهستونی مثل ROWNUM ندارد؛ معادل منطقیاش همیشه درست کار میکند
SELECT name, salary FROM employee ORDER BY salary DESC LIMIT 3;
-- و اگر شمارهٔ ردیف در هر صفحه را میخواهی:
SELECT * FROM (
SELECT t.*, ROW_NUMBER() OVER (ORDER BY salary DESC, id) AS rn FROM employee t
) x WHERE rn BETWEEN 21 AND 30;-- غلط: ROWNUM پیش از ORDER BY مقدار میگیرد
-- -> سه ردیف دلخواه گرفته میشود و بعد همان سهتا مرتب میشوند
SELECT name, salary FROM employee WHERE ROWNUM <= 3 ORDER BY salary DESC;
-- درستِ کلاسیک (پیش از 12c): اول در inline view مرتب کن، بعد بیرون ROWNUM بزن
SELECT * FROM (SELECT name, salary FROM employee ORDER BY salary DESC)
WHERE ROWNUM <= 3;
-- درستِ مدرن (12c به بعد):
SELECT name, salary FROM employee ORDER BY salary DESC FETCH FIRST 3 ROWS ONLY;ROWNUM فقط وقتی به یک ردیف تخصیص مییابد که آن ردیف از فیلتر عبور کند. اولین کاندید ROWNUM = 1 میگیرد؛ شرط ROWNUM > 1 رد میشود، پس آن ردیف دور ریخته میشود و شمارنده جلو نمیرود. ردیف بعدی هم دوباره کاندیدِ ROWNUM = 1 است… و این تا آخر جدول ادامه دارد. نتیجه: صفر ردیف، برای همیشه. به همین دلیل صفحهبندی قدیمی Oracle همیشه به دو لایهٔ تودرتو نیاز داشت.
OFFSET 100000 یعنی موتور باید صد هزار ردیف را واقعاً تولید کند و دور بریزد. هزینه با شمارهٔ صفحه خطی بالا میرود. راهحل حرفهای keyset pagination (یا seek method) است: بهجای «۱۰ تا بعد از ردیف صدهزارم»، بگو «۱۰ تا بعد از این مقدارِ مشخص». با ایندکس روی ستونهای ترتیب، هزینه ثابت میشود.
-- PostgreSQL مقایسهٔ چندستونیِ ردیفی را مستقیم پشتیبانی میکند
SELECT id, name, salary FROM employee
WHERE (salary, id) < (7500, 5) -- «کلید صفحهٔ قبل» را از کلاینت میگیری
ORDER BY salary DESC, id DESC
FETCH FIRST 10 ROWS ONLY;-- Oracle مقایسهٔ ردیفی را فقط برای = و IN دارد، نه برای < و >
-- پس باید شرط را دستی باز کنی:
SELECT id, name, salary FROM employee
WHERE salary < 7500
OR (salary = 7500 AND id < 5)
ORDER BY salary DESC, id DESC
FETCH FIRST 10 ROWS ONLY;شکل بازشده در Oracle نهتنها طولانیتر است، بلکه بهینهساز هم سختتر میتواند آن را به یک INDEX RANGE SCAN تمیز تبدیل کند (بهخاطر OR وسطش). اگر روی Oracle به صفحهبندی عمیقِ پرسرعت نیاز داری، یک ایندکس ترکیبی روی (salary DESC, id DESC) بساز و پلن را با DBMS_XPLAN چک کن.
NULL — سهارزشی، و تلهٔ رشتهٔ خالی در Oracle
NULL یک مقدار نیست؛ «نامعلوم» است. NULL = NULL برابر UNKNOWN است نه true — در هر دو موتور. اما یک تفاوت هست که فقط مالِ Oracle است:
-- PostgreSQL: رشتهٔ خالی یک مقدارِ واقعی با طول صفر است، NULL نیست
SELECT CASE WHEN '' IS NULL THEN 'empty string IS NULL'
ELSE 'empty string is NOT null' END AS truth,
LENGTH('') AS len;
-- خروجی: empty string is NOT null | 0-- Oracle: رشتهٔ خالی و NULL یک چیزند (برای VARCHAR2)
SELECT CASE WHEN '' IS NULL THEN 'empty string IS NULL'
ELSE 'empty string is NOT null' END AS truth,
LENGTH('') AS len
FROM dual;
-- خروجی: empty string IS NULL | (NULL) -- حتی LENGTH('') هم NULL است، نه صفرسه پیامد عملی: (۱) در Oracle نمیتوانی ستون NOT NULL را با رشتهٔ خالی پر کنی — ORA-01400: cannot insert NULL. (۲) WHERE name = '' در Oracle همیشه صفر ردیف میدهد؛ باید IS NULL بنویسی. (۳) در PostgreSQL این دو حالت کاملاً متفاوتاند و منطق برنامهات روی هرکدام رفتار دیگری دارد. اگر کدت باید روی هر دو موتور بچرخد، هرگز به تفاوت '' و NULL تکیه نکن و ورودی را قبل از ذخیره normal کن.
-- PostgreSQL بند استاندارد IS [NOT] DISTINCT FROM را دارد: هرگز UNKNOWN نمیدهد
SELECT a.name, b.name FROM employee a JOIN employee b
ON a.dept_id IS NOT DISTINCT FROM b.dept_id -- دو NULL «برابر» شمرده میشوند
AND a.id < b.id;
SELECT name, dept_id FROM employee ORDER BY dept_id ASC NULLS FIRST;-- Oracle بند IS [NOT] DISTINCT FROM را ندارد. دو معادل اصطلاحی:
-- ۱) DECODE دو NULL را برابر میشمارد (رفتار ویژه و مستندِ DECODE)
SELECT a.name, b.name FROM employee a JOIN employee b
ON DECODE(a.dept_id, b.dept_id, 0, 1) = 0 AND a.id < b.id;
-- ۲) شکل صریح و پرتابل
SELECT a.name, b.name FROM employee a JOIN employee b
ON (a.dept_id = b.dept_id OR (a.dept_id IS NULL AND b.dept_id IS NULL))
AND a.id < b.id;
SELECT name, dept_id FROM employee ORDER BY dept_id ASC NULLS FIRST;هر دو NULL را «بزرگترین مقدار» میشمارند؛ یعنی پیشفرضِ ORDER BY col ASC برابر NULLS LAST و پیشفرضِ DESC برابر NULLS FIRST است (برخلاف MySQL و SQL Server). با این حال در کد تولیدی همیشه صریح بنویس — هم برای خوانایی، هم چون ایندکس فقط وقتی میتواند مرتبسازی را رایگان تأمین کند که ترتیبِ NULL آن با پرسوجو بخوانَد.
LNNVL(condition) وقتی true برمیگرداند که شرط false یا UNKNOWN باشد. کاربرد اصلیاش دقیقاً همان تلهٔ NOT IN با NULL است: WHERE LNNVL(dept_id = 1) هم ردیفهای dept_id <> 1 و هم dept_id IS NULL را برمیگرداند. معادل PostgreSQL آن WHERE dept_id IS DISTINCT FROM 1 است.
ایندکسها و برنامهریز
یک کتاب هزار صفحهای داری و میخواهی هر جا کلمهٔ «قلب» آمده را پیدا کنی. بدون فهرست باید کل کتاب را بخوانی؛ با فهرستِ الفباییِ انتهای کتاب مستقیم میروی سراغ «ق». ایندکس همان فهرست است: یک ساختار کمکی و مرتب که اسکن O(n) را به جستوجوی O(log n) تبدیل میکند. پیشفرض در هر دو موتور B-tree است (در Oracle دقیقترش B*-tree، ولی رفتار عملی یکی است) و عالی است برای تساوی، بازه، IN، مرتبسازی و پیشوندِ LIKE 'abc%'.
دفترچهٔ الفبایی به تو اجازه میدهد فوراً به «محمدی» بروی (تساوی)، همهٔ «م تا ن» را پشتسرهم بخوانی (بازه)، یا همهٔ «محمد...»ها را پیدا کنی (پیشوند). اما اگر بخواهی همهٔ کسانی که نام کوچکشان علی است را پیدا کنی، دفترچهٔ مرتبشده بر نامخانوادگی هیچ کمکی نمیکند. این دقیقاً دلیل قاعدهٔ بعدی است.
ایندکس ترکیبی و قاعدهٔ پیشوند چپترین
CREATE INDEX ix_emp_dept_sal ON employee (dept_id, salary);
SELECT * FROM employee WHERE dept_id = 1 AND salary > 8000; -- کامل استفاده میشود
SELECT * FROM employee WHERE dept_id = 1 ORDER BY salary DESC; -- مرتبسازی رایگان
SELECT * FROM employee WHERE salary > 8000; -- نمیتواندCREATE INDEX ix_emp_dept_sal ON employee (dept_id, salary);
SELECT * FROM employee WHERE dept_id = 1 AND salary > 8000;
SELECT * FROM employee WHERE dept_id = 1 ORDER BY salary DESC;
-- Oracle اینجا گاهی INDEX FAST FULL SCAN انتخاب میکند (کل ایندکس را مثل یک
-- جدول باریک میخواند) — بهتر از TABLE ACCESS FULL، ولی بدتر از range scan واقعی
SELECT * FROM employee WHERE salary > 8000;قاعدهٔ عملی: ستونهای تساوی اول، ستون بازه/مرتبسازی آخر.
دفترچهای که اول بر اساس شهر و بعد درون هر شهر بر اساس نامخانوادگی مرتب است. «تهران / محمدی» آسان است. همهٔ «محمدی»ها در کل کشور؟ سخت، چون در هر شهر جدا مرتب شدهاند و پخشاند. ستونِ چپ کلیدِ ورود است.
در Oracle، ایندکس B-tree ردیفهایی را که همهٔ ستونهای کلیدشان NULL است اصلاً ذخیره نمیکند. نتیجه: WHERE dept_id IS NULL روی ایندکس تکستونی هرگز از آن استفاده نمیکند و به TABLE ACCESS FULL میرسد. راهحل کلاسیک: ایندکس را ترکیبی کن ((dept_id, id)) تا کلید هرگز کاملاً NULL نباشد. PostgreSQL برعکس، NULLها را ذخیره میکند و IS NULL را با Index Scan جواب میدهد.
ایندکس پوشاننده و اسکن صرفاً ایندکسی
اگر ایندکس همهٔ ستونهای موردنیازِ پرسوجو را داشته باشد، میشود بدون لمس جدول پاسخ داد:
-- از PostgreSQL 11: ستونهای غیرکلیدی با INCLUDE سوار ایندکس میشوند
-- (در کلیدِ مرتبسازی شرکت نمیکنند، فقط «همراه» هستند)
CREATE INDEX ix_cover ON employee (dept_id, salary) INCLUDE (name);
EXPLAIN (ANALYZE, BUFFERS)
SELECT dept_id, salary, name FROM employee WHERE dept_id = 1;
-- در پلن دنبال «Index Only Scan» و «Heap Fetches: 0» بگرد-- Oracle بند INCLUDE ندارد: ستون اضافه صرفاً به کلید اضافه میشود
CREATE INDEX ix_cover ON employee (dept_id, salary, name);
SELECT /*+ GATHER_PLAN_STATISTICS */ dept_id, salary, name
FROM employee WHERE dept_id = 1;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
-- نشانهٔ پوشاننده بودن: مرحلهٔ TABLE ACCESS BY INDEX ROWID در پلن «نیست»PostgreSQL نسخههای قدیمیِ ردیفها را داخل خودِ جدول نگه میدارد و ایندکس نمیداند یک ردیف برای تراکنش تو قابلرؤیت هست یا نه. پس حتی در Index Only Scan باید visibility map را چک کند و اگر صفحهای «کاملاً قابلرؤیت» علامت نخورده باشد به جدول برمیگردد (Heap Fetches > 0)؛ درمانش VACUUM است. Oracle این مشکل را ندارد چون نسخههای قدیمی را در undo tablespace جدا نگه میدارد. اگر در PostgreSQL یک index-only scan داری که سریع نیست، اولین چیزی که باید ببینی Heap Fetches و وضعیت autovacuum است.
ایندکس جزئی
-- PostgreSQL ایندکس جزئی بومی دارد: فقط ردیفهای منطبق ذخیره میشوند
CREATE INDEX ix_active_hi ON employee (salary)
WHERE hired_on >= DATE '2022-01-01';
SELECT * FROM employee
WHERE hired_on >= DATE '2022-01-01' AND salary > 7000;-- Oracle چنین چیزی ندارد. ترفند استاندارد: یک function-based index که برای
-- ردیفهای ناخواسته NULL برمیگرداند — و چون B-tree کلید کاملاً NULL را
-- ذخیره نمیکند، عملاً ایندکس جزئی میشود.
CREATE INDEX ix_active_hi ON employee
(CASE WHEN hired_on >= DATE '2022-01-01' THEN salary END);
-- پرسوجو باید *دقیقاً* همان عبارت را داشته باشد تا ایندکس بخورد
SELECT * FROM employee
WHERE (CASE WHEN hired_on >= DATE '2022-01-01' THEN salary END) > 7000;Oracle از 12c چیزی به نام partial index دارد، ولی یعنی «ایندکس فقط روی بعضی پارتیشنهای یک جدول پارتیشنبندیشده» (INDEXING ON/OFF در سطح پارتیشن) — نه ایندکس شرطمحورِ PostgreSQL. اگر مصاحبهگر Oracle این واژه را گفت، احتمالاً منظورش پارتیشن است.
چه زمانی ایندکسها نادیده گرفته میشوند
sargable (Search ARGument ABLE) یعنی شرطی که ایندکس میتواند مستقیم رویش کار کند. برنامهریز ممکن است (بهدرستی) ایندکس را نادیده بگیرد وقتی: شرط sargable نیست (تابع روی ستون)، wildcard پیشرو داری (LIKE '%ada')، پرسوجو کسر بزرگی از جدول را برمیگرداند، آمار کهنه است، یا تبدیل نوع ضمنی رخ داده.
-- درمان: ایندکس تابعی (expression index)
CREATE INDEX ix_emp_lname ON employee (lower(name));
SELECT * FROM employee WHERE lower(name) = 'ada';
-- برای جستوجوی زیررشتهای ('%ada%') B-tree بیفایده است؛ trigram لازم است
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX ix_emp_name_trgm ON employee USING gin (name gin_trgm_ops);-- درمان مشابه: function-based index (از 8i)
CREATE INDEX ix_emp_lname ON employee (LOWER(name));
SELECT * FROM employee WHERE LOWER(name) = 'ada';
-- برای زیررشته، Oracle Text لازم است:
-- CREATE INDEX ix_name_txt ON employee(name) INDEXTYPE IS CTXSYS.CONTEXT;
-- ابزار طلاییِ Oracle برای آزمایش: ایندکس را نامرئی کن، نه حذف
ALTER INDEX ix_emp_lname INVISIBLE; -- بهینهساز نمیبیندش، ولی نگهداری میشود
ALTER INDEX ix_emp_lname VISIBLE; -- برگشت آنیدر Oracle INVISIBLE کن و چند روز رصد کن؛ ایندکس دستنخورده میماند پس برگرداندنش آنی است. در PostgreSQL معادل INVISIBLE نداری، اما یک ترفند زیبا داری که مستقیماً از تراکنشیبودن DDL میآید: BEGIN; DROP INDEX ix_x; EXPLAIN (ANALYZE) SELECT …; ROLLBACK; — پلن را بدون ایندکس میبینی و بعد همهچیز برمیگردد. (فقط بدان DROP INDEX قفل سنگین میگیرد، پس روی محیط تولیدِ شلوغ این کار را نکن.)
هر ایندکسِ اضافی نوشتنها را کندتر و جدول را سنگینتر میکند. در PostgreSQL با pg_stat_user_indexes ببین کدامها هرگز اسکن نشدهاند (idx_scan = 0). در Oracle از 12.2 DBA_INDEX_USAGE همین کار را میکند (در نسخههای قدیمیتر ALTER INDEX … MONITORING USAGE و V$OBJECT_USAGE).
آمار — سوختِ برنامهریز
ANALYZE employee;
ALTER TABLE employee ALTER COLUMN dept_id SET STATISTICS 500;
-- آمار چندستونی برای وقتی دو ستون به هم وابستهاند
CREATE STATISTICS st_emp (dependencies, ndistinct) ON dept_id, salary FROM employee;
ANALYZE employee;
SELECT relname, n_live_tup, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'employee';BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => USER,
tabname => 'EMPLOYEE',
method_opt => 'FOR ALL COLUMNS SIZE AUTO', -- هیستوگرام خودکار
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
cascade => TRUE); -- ایندکسها را هم شامل شود
END;
/
-- معادل CREATE STATISTICS در Oracle: extended statistics
SELECT DBMS_STATS.CREATE_EXTENDED_STATS(USER,'EMPLOYEE','(dept_id, salary)') FROM dual;
SELECT table_name, num_rows, last_analyzed FROM user_tables
WHERE table_name = 'EMPLOYEE';وقتی برنامهریز فکر میکند ۱۰ ردیف برمیگردد ولی واقعاً ۱۰ میلیون برمیگردد، تصمیمِ nested loop میگیرد و پرسوجو ساعتها طول میکشد. PostgreSQL این کار را با autoanalyze خودکار میکند ولی روی جدولهای بسیار بزرگ آستانهها دیر میرسند؛ Oracle یک job شبانه دارد که ممکن است در پنجرهٔ نگهداری تمام نشود. بعد از هر بارگذاری انبوه، دستی آمار را بهروز کن.
خواندن EXPLAIN: دو موتور، دو فرهنگ
PostgreSQL همهچیز را در یک دستور میدهد؛ Oracle یک آیین دو مرحلهای دارد:
-- EXPLAIN تنها = پلن تخمینی، بدون اجرا
EXPLAIN SELECT e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;
-- EXPLAIN ANALYZE = واقعاً اجرا میکند و زمان و تعداد ردیف واقعی میدهد
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;-- (الف) پلن تخمینی، بدون اجرا
EXPLAIN PLAN FOR
SELECT e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'ALL'));
-- (ب) معادل واقعیِ EXPLAIN ANALYZE: اجرا با آمار row-source، بعد خواندن آن
SELECT /*+ GATHER_PLAN_STATISTICS */ e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
-- (ج) راه سریع در SQL*Plus / SQLcl
SET AUTOTRACE ONEXPLAIN PLAN FOR پرسوجو را اجرا نمیکند، پس مقدار واقعیِ متغیرهای bind را نمیبیند و ممکن است پلنی نشان دهد که با پلنِ واقعی فرق دارد (بهخاطر bind peeking و adaptive plans). این یکی از بزرگترین اشتباهات تازهکارها در Oracle است. اگر میتوانی پرسوجو را اجرا کنی، همیشه GATHER_PLAN_STATISTICS + DISPLAY_CURSOR('ALLSTATS LAST') را ترجیح بده — این ترکیب دقیقاً معادلِ EXPLAIN ANALYZE است.
خروجی نمونهٔ PostgreSQL:
Hash Join (cost=1.07..2.20 rows=3 width=64)
(actual time=0.030..0.041 rows=3 loops=1)
Hash Cond: (e.dept_id = d.id)
Buffers: shared hit=4
-> Seq Scan on employee e (cost=0.00..1.09 rows=3 width=36)
(actual time=0.010..0.014 rows=3 loops=1)
Filter: (salary > 8000)
Rows Removed by Filter: 3
-> Hash (cost=1.03..1.03 rows=3 width=36)
-> Seq Scan on department d ...
Planning Time: 0.20 ms
Execution Time: 0.06 ms
خروجی نمونهٔ Oracle با ALLSTATS LAST:
---------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | A-Rows | Buffers |
---------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 3 | 14 |
|* 1 | HASH JOIN | | 1 | 3 | 3 | 14 |
| 2 | TABLE ACCESS FULL | DEPARTMENT | 1 | 3 | 3 | 7 |
|* 3 | TABLE ACCESS FULL | EMPLOYEE | 1 | 3 | 3 | 7 |
---------------------------------------------------------------------------------
Predicate Information (identified by operation id):
1 - access("D"."ID"="E"."DEPT_ID")
3 - filter("E"."SALARY">8000)
جدول ترجمهٔ ستونها — هستهٔ خواندن پلن در هر دو موتور:
| چه میخواهی بدانی | PostgreSQL | Oracle |
|---|---|---|
| تخمین تعداد ردیف | rows= در cost=… |
E-Rows |
| تعداد ردیف واقعی | actual rows= |
A-Rows |
| چند بار این گره اجرا شد | loops= |
Starts |
| کارِ I/O | Buffers: shared hit / read |
Buffers و Reads |
| کارِ هدررفته | Rows Removed by Filter |
بخش Predicate + اختلاف A-Rows |
شکاف بین تخمین و واقعیت (rows در برابر actual rows، یا E-Rows در برابر A-Rows) اولین چیزی است که باید نگاه کنی. اگر برنامهریز ۱۰ ردیف حدس زده و ۱۰۰٬۰۰۰ ردیف آمده، هر تصمیم بعدیاش — انتخاب nested loop، ترتیب join، انتخاب ایندکس — بر پایهٔ یک دروغ ساخته شده. و در Nested Loop یادت باشد هزینهٔ گره درونی را در loops/Starts ضرب کنی.
و برای پیدا کردن اینکه اصلاً کدام پرسوجو مقصر است:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT queryid, calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
FETCH FIRST 10 ROWS ONLY;-- معادل بومی Oracle: نمای V$SQL (و برای تحلیل تاریخی، AWR / ASH)
SELECT sql_id, executions,
ROUND(elapsed_time/1e6, 1) AS total_sec,
ROUND(elapsed_time/GREATEST(executions,1)/1e3, 2) AS mean_ms,
buffer_gets, rows_processed, sql_text
FROM v$sql
ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;سه مسئلهٔ کلاسیک مصاحبه، در هر دو موتور
۱. دومین حقوق بیشینه
-- رویکرد window (تساویها را با DENSE_RANK مدیریت میکند)
WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employee
)
SELECT DISTINCT salary FROM ranked WHERE r = 2;
-- زیرپرسوجوی کلاسیک: همیشه دقیقاً یک ردیف، امن در برابر نبودِ مقدار دوم
SELECT MAX(salary) AS second_highest FROM employee
WHERE salary < (SELECT MAX(salary) FROM employee);
-- شکل row-limiting (رفتار NULL متفاوتی دارد!)
SELECT DISTINCT salary FROM employee
ORDER BY salary DESC OFFSET 1 ROWS FETCH FIRST 1 ROW ONLY;WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employee
)
SELECT DISTINCT salary FROM ranked WHERE r = 2;
SELECT MAX(salary) AS second_highest FROM employee
WHERE salary < (SELECT MAX(salary) FROM employee);
SELECT DISTINCT salary FROM employee
ORDER BY salary DESC OFFSET 1 ROWS FETCH FIRST 1 ROW ONLY;فرم زیرپرسوجو ظرافت مهمی دارد: اگر حقوق دومی نباشد، بهآرامی NULL برمیگرداند — چون aggregate روی مجموعهٔ خالی همیشه یک ردیف میدهد. در مقابل، فرم OFFSET 1 FETCH FIRST 1 در آن حالت هیچ ردیفی برنمیگرداند، و در کلاینت تبدیل به exception میشود. دقیقاً همان جزئیاتی که در مصاحبه امتیاز میآورد.
۲. Top-N بهازای هر گروه («greatest-N-per-group»)
-- الگوی متعارف و پرتابل
SELECT dept_id, name, salary FROM (
SELECT e.*,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) AS rn
FROM employee e
) t WHERE rn <= 2; -- دو نفر برتر در هر دپارتمان
-- میانبر مخصوص PostgreSQL برای top-1
SELECT DISTINCT ON (dept_id) dept_id, name, salary
FROM employee ORDER BY dept_id, salary DESC, id;SELECT dept_id, name, salary FROM (
SELECT e.*,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) AS rn
FROM employee e
) t WHERE rn <= 2;
-- میانبر مخصوص Oracle برای top-1 (یک عبور، بدون subquery)
SELECT dept_id,
MAX(name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC, id) AS name,
MAX(salary) KEEP (DENSE_RANK FIRST ORDER BY salary DESC, id) AS salary
FROM employee GROUP BY dept_id;ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) وقتی دو نفر حقوق برابر دارند غیرقطعی است. اضافهکردن , id نتیجه را قطعی میکند. این را در مصاحبه خودت بگو حتی اگر نپرسند — نشانهٔ روشنِ تجربهٔ محیط تولید است. اگر باید همهٔ همرتبهها بیایند، RANK/DENSE_RANK جایگزین کن.
۳. شکافها و جزیرهها (gaps and islands)
ترفند جادویی: value - ROW_NUMBER() در طول یک بازهٔ پیوسته ثابت است، چون مقدار و شمارهٔ ردیف با هم یکییکی زیاد میشوند؛ ولی بهمحض یک شکاف، مقدار میپرد و شماره نه.
CREATE TABLE logins (user_id INT, day INT);
INSERT INTO logins VALUES (1,1),(1,2),(1,3),(1,6),(1,7),(1,10);
WITH grp AS (
SELECT day, day - ROW_NUMBER() OVER (ORDER BY day) AS island_key
FROM logins WHERE user_id = 1
)
SELECT MIN(day) AS start_day, MAX(day) AS end_day, COUNT(*) AS len
FROM grp GROUP BY island_key ORDER BY start_day;
-- جزیرهها: [1-3]، [6-7]، [10-10]؛ شکافها 4-5 و 8-9 هستندCREATE TABLE logins (user_id NUMBER(10), day NUMBER(10));
INSERT ALL
INTO logins VALUES (1,1) INTO logins VALUES (1,2) INTO logins VALUES (1,3)
INTO logins VALUES (1,6) INTO logins VALUES (1,7) INTO logins VALUES (1,10)
SELECT * FROM dual;
COMMIT;
WITH grp AS (
SELECT day, day - ROW_NUMBER() OVER (ORDER BY day) AS island_key
FROM logins WHERE user_id = 1
)
SELECT MIN(day) AS start_day, MAX(day) AS end_day, COUNT(*) AS len
FROM grp GROUP BY island_key ORDER BY start_day;شمارهی صندلیهای اشغالشده را داری. اگر افراد پشتسرهم نشسته باشند (۱،۲،۳)، «شمارهی صندلی منهای شمارندهی ترتیبی» برای همهشان یکی است — انگار یک برچسبِ گروهی دارند. تا وقتی یک صندلی خالی وسط بیفتد، برچسب عوض میشود و گروه جدید شروع میشود.
Oracle یک اسلحهٔ اضافه دارد که فقط خودش دارد: MATCH_RECOGNIZE (از 12c) — تطبیق الگو روی ردیفهای مرتب:
-- PostgreSQL بند MATCH_RECOGNIZE ندارد؛ ترفند island_key معادل پرتابل آن است
WITH grp AS (
SELECT user_id, day,
day - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day) AS island_key
FROM logins
)
SELECT user_id, MIN(day) AS start_day, MAX(day) AS end_day, COUNT(*) AS len
FROM grp GROUP BY user_id, island_key ORDER BY user_id, start_day;-- MATCH_RECOGNIZE: الگو را مستقیم توصیف میکنی، بدون ترفند ریاضی
SELECT * FROM logins
MATCH_RECOGNIZE (
PARTITION BY user_id
ORDER BY day
MEASURES FIRST(day) AS start_day, LAST(day) AS end_day, COUNT(*) AS len
ONE ROW PER MATCH
PATTERN (a b*)
DEFINE b AS b.day = PREV(b.day) + 1
);اگر روی Oracle کار میکنی و مسئلهات «دنبالهٔ رویدادها» است (سه ورود ناموفق پشتسرهم، الگوی V در قیمت، sessionسازی با فاصلهٔ کمتر از ۳۰ دقیقه)، MATCH_RECOGNIZE مسئله را از صد خط SQL به ده خط میرساند. در PostgreSQL باید همه را با window function و island_key بسازی. دانستن اینکه چنین ابزاری هست، خودش یک تفاوت سطح سنیور است.
جدول تطبیق سریع PostgreSQL ↔ Oracle
این جدول را جدا نگه دار؛ دقیقاً همان چیزی است که در مصاحبه یا وسط یک مهاجرت لازمت میشود:
| موضوع | PostgreSQL 16/17 | Oracle 19c / 23ai |
|---|---|---|
| متن | TEXT / VARCHAR(n) |
VARCHAR2(n) · CLOB |
| عدد دقیق | NUMERIC(p,s) |
NUMBER(p,s) |
| تاریخ | DATE (فقط تاریخ) · timestamptz |
DATE (تاریخ و ساعت) · TIMESTAMP WITH TIME ZONE |
| زمان جاری | now() (شروع تراکنش) · clock_timestamp() |
SYSDATE · SYSTIMESTAMP |
| رشتهٔ خالی | مقدارِ واقعی، ≠ NULL | برابر NULL |
| جدول ساختگی | لازم نیست | DUAL (تا 23ai که اختیاری شد) |
| صفحهبندی | LIMIT n OFFSET m · FETCH FIRST |
OFFSET m ROWS FETCH FIRST n ROWS ONLY (12c+) · ROWNUM |
| upsert | INSERT … ON CONFLICT · MERGE (15+) |
MERGE (9i+) |
| بهروزرسانی از جدول دیگر | UPDATE … FROM |
MERGE یا زیرپرسوجوی همبسته |
| بازگرداندن ردیف درجشده | RETURNING (result set) |
RETURNING … INTO (فقط bind) |
| تجمیع رشته | STRING_AGG(x, s ORDER BY …) |
LISTAGG(x, s) WITHIN GROUP (ORDER BY …) |
| تجمیع شرطی | agg(...) FILTER (WHERE …) |
agg(CASE WHEN … THEN … END) |
| تفاضل مجموعه | EXCEPT / EXCEPT ALL |
MINUS (و از 21c EXCEPT) |
| CTE بازگشتی | WITH RECURSIVE، لیست ستون اختیاری |
WITH بدون RECURSIVE، لیست ستون اجباری |
| سلسلهمراتب | فقط CTE بازگشتی | CTE بازگشتی و CONNECT BY |
| مقایسهٔ NULL-امن | IS [NOT] DISTINCT FROM |
DECODE(a,b,0,1)=0 · LNNVL |
IGNORE NULLS در window |
ندارد (تا 17) | دارد |
| اولین ردیف هر گروه | DISTINCT ON |
KEEP (DENSE_RANK FIRST ORDER BY …) |
| تطبیق الگوی ردیفی | ندارد | MATCH_RECOGNIZE (12c+) |
| ایندکس جزئی | CREATE INDEX … WHERE … |
function-based index با CASE … END |
| ستونهای همراهِ ایندکس | INCLUDE (...) |
ستون را به کلید اضافه کن |
| NULL در B-tree | ذخیره میشود | کلیدِ کاملاً NULL ذخیره نمیشود |
| خاموشکردن موقت ایندکس | ندارد (DROP داخل تراکنش) |
ALTER INDEX … INVISIBLE |
| بهروزرسانی آمار | ANALYZE · CREATE STATISTICS |
DBMS_STATS.GATHER_TABLE_STATS |
| پلن با آمار واقعی | EXPLAIN (ANALYZE, BUFFERS) |
/*+ GATHER_PLAN_STATISTICS */ + DISPLAY_CURSOR('ALLSTATS LAST') |
| پرسوجوهای گران | pg_stat_statements |
V$SQL · AWR · ASH |
| hint بهینهساز | ندارد (pg_hint_plan) |
سیستم کامل /*+ … */ · DBMS_SPM |
| تراکنشی بودن DDL | بله | خیر (commit ضمنی) |
اشتباهات و تلههای رایج
- NULL یک مقدار نیست؛ «نامعلوم» است.
WHERE x = NULLهرگز منطبق نمیشود — ازIS NULLاستفاده کن. NOT INبا NULLها هیچ ردیفی برنمیگرداند — در هر دو موتور. همیشهNOT EXISTS.COUNT(column)مقادیر NULL را رد میکند، اماCOUNT(*)نه.AVGهم NULL را نادیده میگیرد.- در Oracle،
''همان NULL است. هر منطقی که به «خالی ولی نه NULL» تکیه کند، در Oracle میشکند. - در Oracle،
DATEساعت هم دارد.col = DATE '2023-05-05'ردیفهای ساعتدار را نمیگیرد؛ بازهٔ نیمباز بنویس. ROWNUMپیش ازORDER BYمقدار میگیرد وWHERE ROWNUM > 1هرگز ردیفی برنمیگرداند.RANGEپیشفرضِ قاب است، نهROWS— در هر دو موتور؛ برای جمع دونده صریحROWSبنویس.- تبدیل نوعِ ضمنی ایندکسها را میکشد: مقایسهٔ ستون عددی با رشته، یا پیچیدن ستون در تابع.
SELECT *در محیط تولید پلنهای ایندکس پوشاننده را میشکند و در برابر تغییرات شِما شکننده است.- پول اعشاری:
NUMERIC/NUMBER، هرگزFLOAT/BINARY_DOUBLE. - در Oracle، DDL تراکنشت را commit میکند. یک
CREATE TABLEوسط اسکریپت، همهٔ تغییرات قبلی را نهایی میکند. ROW_NUMBERبدون tiebreaker غیرقطعی است و در صفحهبندی باعث میشود یک ردیف در دو صفحه یا هیچ صفحهای ظاهر شود.
بهترین شیوهها
- زود فیلتر کن (
WHEREپیش ازHAVING)، فقط ستونهای لازم را انتخاب کن، و برای شکل واقعی پرسوجوهایت ایندکس بساز — باEXPLAIN ANALYZEیاDISPLAY_CURSORتأیید کن، حدس نزن. - ایندکس ترکیبی را با ستونهای تساوی اول، ستون بازه/مرتبسازی آخر طراحی کن.
- برای بررسی وجودیِ همبسته و امنیت NULL،
EXISTS/NOT EXISTSرا بهIN/NOT INترجیح بده. - آمار را بهروز نگه دار:
ANALYZEدر PostgreSQL،DBMS_STATSدر Oracle — بهویژه بعد از بارگذاری انبوه. - برای رتبهبندی، جمع دونده و مقایسهٔ بینردیفی، پیش از self-join سراغ window function برو.
- در کد جدید نحو استانداردِ مشترک را ترجیح بده:
JOIN … ON(نه(+))،FETCH FIRST(نهLIMIT/ROWNUM)،CASE/COALESCE(نهDECODE/NVL)، CTE بازگشتی (نهCONNECT BY). - هرجا از ویژگی اختصاصی استفاده کردی (
ON CONFLICT،DISTINCT ON،KEEP DENSE_RANK،MATCH_RECOGNIZE)، یک کامنت بگذار که معادل موتور دیگر چیست.
سؤالات مصاحبه
اول خودت جواب بده، بعد پاسخ را بخوان.
WHERE ردیفهای منفرد را پیش از گروهبندی فیلتر میکند و نمیتواند از aggregate استفاده کند. HAVING گروهها را پس از GROUP BY فیلتر میکند و میتواند. تا حد امکان شرطها را در WHERE بگذار تا ورودیِ تجمیع کوچک شود. در هر دو موتور یکسان است؛ فقط کد خطا فرق دارد (42803 در PostgreSQL، ORA-00979/ORA-00934 در Oracle).
برای ۹۰۰۰، ۹۰۰۰، ۸۰۰۰: ROW_NUMBER → ۱،۲،۳ (همیشه یکتا)؛ RANK → ۱،۱،۳ (رتبهٔ مشترک، سپس شکاف)؛ DENSE_RANK → ۱،۱،۲ (بدون شکاف). هر سه در PostgreSQL و Oracle یکساناند — Oracle اینها را از 8i دارد و analytic function مینامدشان. برای دقیقاً-N از ROW_NUMBER با tiebreaker، و برای «همهٔ همرتبهها» از DENSE_RANK.
ROWNUM شبهستونی مخصوص Oracle است که در مرحلهٔ فیلتر و پیش از ORDER BY مقدار میگیرد؛ پس WHERE ROWNUM <= 3 ORDER BY salary DESC سه ردیف دلخواه میدهد نه سه نفر برتر، و باید داخل inline view مرتبشده بهکار برود. ضمناً WHERE ROWNUM > 1 همیشه صفر ردیف میدهد، چون شمارنده فقط برای ردیفهایی که از فیلتر عبور کنند جلو میرود. ROW_NUMBER() یک window function استاندارد و پس از مرتبسازیِ پنجره است و در هر دو موتور یکسان کار میکند و تنها راه «شمارهٔ ردیف در هر گروه» است. FETCH FIRST n ROWS ONLY بند استاندارد ANSI است (Oracle 12c+، PostgreSQL 8.4+) و امروز شکل درست و پرتابل. PostgreSQL معادلی برای ROWNUM ندارد.
شرطی روی ستون جدول راست در WHERE، ردیفهای بیرونیِ پرشده با NULL را حذف میکند (چون NULL = x برابر UNKNOWN است) و بیصدا LEFT JOIN را به INNER تنزل میدهد. راهحل: شرط را به ON منتقل کن. در نحو قدیمی Oracle همین وقتی رخ میدهد که یک (+) را روی یکی از شرطهای همان جدول جا بیندازی — دلیل دیگری برای نوشتن نحو ANSI.
در Oracle، '' برای VARCHAR2 دقیقاً همان NULL است: '' IS NULL برابر true، LENGTH('') برابر NULL، و WHERE name = '' همیشه صفر ردیف. در PostgreSQL، '' رشتهٔ واقعیِ طولصفر است و کاملاً از NULL جداست. پیامدها: در Oracle نمیتوانی ستون NOT NULL را با '' پر کنی (ORA-01400)، و منطقی که «خالی» و «تعریفنشده» را جدا میکند در مهاجرت میشکند. قاعده: هرگز به تفاوت '' و NULL تکیه نکن.
DATE در PostgreSQL فقط سال/ماه/روز است؛ در Oracle همیشه ساعت، دقیقه و ثانیه هم دارد. باگ کلاسیک: WHERE hired_on = DATE '2023-05-05' فقط ردیفهای ساعتِ ۰۰:۰۰:۰۰ را میگیرد. راهحل غلط TRUNC(hired_on) = … است که ایندکس را میکشد؛ راه درست بازهٔ نیمباز >= DATE '2023-05-05' AND < DATE '2023-05-06' است. اگر واقعاً به TRUNC نیاز داری، function-based index بساز. برای دقت بیشتر از ثانیه، TIMESTAMP لازم است.
ON CONFLICT مخصوص PostgreSQL است، روی یک قید یکتا عمل میکند و در برابر همزمانی رفتار امنی دارد. MERGE استاندارد ANSI است (Oracle 9i+، PostgreSQL 15+)، روی یک شرط join عمل میکند، میتواند همزمان UPDATE/INSERT/DELETE داشته باشد و منبعش یک پرسوجوی کامل باشد — پس برای بارگذاری دستهای قویتر است، ولی ذاتاً اتمیک-در-برابر-همزمانی نیست و میتواند به خطای یکتایی بخورد. در Oracle اگر منبع برای یک ردیف مقصد بیش از یک تطابق بدهد، ORA-30926 میگیری.
وقتی شرط sargable نیست (تابع روی ستون، wildcard پیشرو، ناسازگاری نوع)، وقتی کسر بزرگی از جدول را انتخاب میکند، وقتی آمار کهنه است، یا وقتی ترتیب ستونهای ایندکس با پرسوجو نمیخواند. یک مورد مخصوص Oracle هم هست: چون B-tree در Oracle کلیدهای کاملاً NULL را ذخیره نمیکند، WHERE col IS NULL روی ایندکس تکستونی هرگز آن ایندکس را استفاده نمیکند — راهحل، ترکیبیکردن ایندکس است. در PostgreSQL این محدودیت نیست.
ایندکس روی (a, b, c) بر اساس a، سپس b، سپس c مرتب است. شرطهای روی a، a,b و a,b,c را پاسخ میدهد، اما نه فقط b یا فقط c را، چون آن ستونها فقط درون گروههای ستون قبلی مرتباند. ستونهای تساوی را اول و ستون بازه/مرتبسازی را آخر بگذار. نکتهٔ Oracle: در نبودِ پیشوند، Oracle ممکن است INDEX FAST FULL SCAN انتخاب کند که از اسکن کامل جدول بهتر است ولی هنوز بسیار بدتر از range scan واقعی.
ایندکس پوشاننده همهٔ ستونهای موردنیاز پرسوجو را دارد، پس بدون مراجعه به جدول جواب میدهد. در PostgreSQL از ۱۱ میتوانی ستونهای غیرکلیدی را با INCLUDE سوار کنی؛ اما چون MVCC نسخههای قدیمی را داخل جدول نگه میدارد، موتور باید visibility map را چک کند و اگر صفحه all-visible نباشد به heap fetch برمیگردد (Heap Fetches > 0) — درمانش VACUUM است. Oracle بند INCLUDE ندارد و ستون را باید به کلید اضافه کنی، ولی بهخاطر undo-based MVCC چنین مشکلی ندارد؛ نشانهٔ موفقیت این است که TABLE ACCESS BY INDEX ROWID در پلن نباشد.
CONNECT BY نحو اختصاصی Oracle است: START WITH لنگر را میدهد، CONNECT BY PRIOR گام را، و ستونهای مصنوعیِ LEVEL، CONNECT_BY_ROOT، SYS_CONNECT_BY_PATH و CONNECT_BY_ISLEAF را رایگان میگیری، بهعلاوهٔ ORDER SIBLINGS BY. CTE بازگشتی استاندارد ANSI است و در PostgreSQL تنها گزینه. سه تفاوت نحوی که باید بلد باشی: PostgreSQL کلیدواژهٔ RECURSIVE میخواهد و Oracle آن را ندارد؛ Oracle لیست ستونهای CTE را اجباری میکند (ORA-32039)؛ بند CYCLE در PostgreSQL یک boolean میسازد و در Oracle دو مقدار متنی (TO 'Y' DEFAULT 'N').
یک تجمیعِ مخصوص Oracle: «در هر گروه، مقدارِ این ستون را از ردیفی بردار که ستون مرتبسازی در آن اول/آخر است» — مثلاً MAX(name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC) یعنی نام پردرآمدترین فرد هر دپارتمان، با یک عبور روی داده و بدون subquery. PostgreSQL معادل مستقیم ندارد؛ سه جایگزین: DISTINCT ON (dept_id) … ORDER BY dept_id, salary DESC وقتی کل ردیف را میخواهی، (ARRAY_AGG(name ORDER BY salary DESC))[1] وقتی یک ستون، یا الگوی ROW_NUMBER() و فیلتر rn = 1.
اگر حتی یک x برابر NULL باشد، عبارت برای هر ردیف به UNKNOWN ارزیابی میشود، پس هیچ ردیفی واجد شرایط نمیشود — در PostgreSQL و Oracle یکسان. راهحل: NOT EXISTS، یا WHERE x IS NOT NULL داخل زیرپرسوجو. در Oracle ابزار سومی هم هست: LNNVL(col = x) که وقتی شرط false یا UNKNOWN باشد true میدهد؛ معادل PostgreSQL آن col IS DISTINCT FROM x است.
ROWS ردیفهای فیزیکی را میشمارد؛ RANGE همترازها با مقادیر ORDER BY برابر را در یک مرز منطقی جمع میکند. اگر ORDER BY بنویسی و قاب را صریح نکنی، پیشفرض RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW است — در هر دو موتور. نتیجه: با کلیدهای مرتبسازی تکراری، همهٔ آن ردیفها یک جمعِ دوندهی یکسان میگیرند. برای جمع دوندهی واقعی همیشه ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW را صریح بنویس.
در PostgreSQL وابسته به نسخه: پیش از ۱۲ همیشه materialize میشد (سدِ بهینهسازی که predicate push-down را میبست)؛ از ۱۲ CTEهای تکارجاعیِ بدون اثر جانبی inline میشوند و با AS MATERIALIZED / AS NOT MATERIALIZED قابل اجبارند — کدی که به سدِ قدیمی تکیه داشت روی ۱۲+ regression میدهد. در Oracle چنین قاعدهٔ نسخهای وجود ندارد: CBO هزینهمحور تصمیم میگیرد و با /*+ MATERIALIZE */ و /*+ INLINE */ هدایت میشود؛ وقتی materialize شود در پلن TEMP TABLE TRANSFORMATION و LOAD AS SELECT میبینی. جملهای که امتیاز میگیرد: «در PostgreSQL این یک قاعدهٔ نحوی است، در Oracle یک تصمیم هزینهمحور.»
EXPLAIN در PostgreSQL فقط تخمین میدهد؛ EXPLAIN ANALYZE واقعاً اجرا میکند و actual rows، loops و (با BUFFERS) I/O واقعی را نشان میدهد — و چون اجرا میشود، برای INSERT/UPDATE/DELETE باید داخل BEGIN … ROLLBACK بگذاریاش. در Oracle این دو ابزار است: EXPLAIN PLAN FOR + DBMS_XPLAN.DISPLAY فقط تخمین میدهد و اجرا نمیکند (پس bind peeking و adaptive plan را نمیبیند و میتواند گمراهکننده باشد)؛ معادل واقعی این است که با /*+ GATHER_PLAN_STATISTICS */ اجرا کنی و بعد DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST') را بخوانی که E-Rows، A-Rows، Starts و Buffers را میدهد. SET AUTOTRACE ON راهِ سریع و کمجزئیاتتر است. برای یافتن پرسوجوی مقصر: pg_stat_statements در برابر V$SQL/AWR.
SELECT MAX(salary) FROM employee WHERE salary < (SELECT MAX(salary) FROM employee); وقتی مقدار دومی نباشد بهتمیزی NULL (یک ردیف) برمیگرداند، چون aggregate روی مجموعهٔ خالی همیشه یک ردیف میدهد. رویکرد ORDER BY salary DESC OFFSET 1 FETCH FIRST 1 ROW ONLY در همان حالت هیچ ردیفی برنمیگرداند — تفاوتی که در کلاینت به exception تبدیل میشود. رویکرد DENSE_RANK() = 2 تساویها را مدیریت میکند. هر سه در PostgreSQL و Oracle یکسان نوشته میشوند اگر بهجای LIMIT از FETCH FIRST استفاده کنی.
برای یک سری صعودیِ یکنوا، value - ROW_NUMBER() OVER (ORDER BY value) در طول یک بازهٔ پیوسته ثابت است و در هر شکاف تغییر میکند؛ بر اساس آن گروهبندی کن و MIN/MAX مرزهای هر جزیره را بده. در هر دو موتور یکسان کار میکند. Oracle یک ابزار اختصاصی هم دارد: MATCH_RECOGNIZE (12c+) که الگو را مستقیم توصیف میکند — PATTERN (a b*) با DEFINE b AS b.day = PREV(b.day) + 1 — و برای دنبالههای پیچیدهتر (سه شکست پشتسرهم، الگوی V، sessionسازی) بسیار خواناتر است. PostgreSQL چنین چیزی ندارد.
SELECT COUNT(*), COUNT(dept_id), COUNT(DISTINCT dept_id) FROM employee;SELECT COUNT(*), COUNT(dept_id), COUNT(DISTINCT dept_id) FROM employee;با دادهٔ بذر (۶ ردیف، dept_id مربوط به Uma برابر NULL، دپارتمانهای ۱،۱،۱،۲،۲): COUNT(*)=6، COUNT(dept_id)=5 (NULL را رد میکند)، COUNT(DISTINCT dept_id)=2. نتیجه در هر دو موتور یکسان است.
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) rn
FROM employee e
) t WHERE rn <= 2;SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) rn
FROM employee e
) t WHERE rn <= 2;پرسوجو در هر دو موتور کلمهبهکلمه یکسان است — نکتهٔ خوبی برای گفتن در مصاحبه. PARTITION BY dept_id رتبهبندی را به هر دپارتمان محدود میکند و , id نتیجه را قطعی. اگر همهٔ همرتبهها باید بیایند، RANK/DENSE_RANK بگذار. برای top-1، PostgreSQL میانبر DISTINCT ON (dept_id) و Oracle میانبر KEEP (DENSE_RANK FIRST ORDER BY salary DESC) را دارد که هر دو معمولاً سریعترند.
SQL دو دنیا دارد که باید همزمان در ذهنت باشند: منطقی (نتیجه چه معنایی دارد) و فیزیکی (پایگاهداده چه میکند). ترتیب واقعی اجرا FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → صفحهبندی است. Joinها INNER و OUTER هستند؛ مراقب تلهٔ ON در برابر WHERE باش، anti-joinها را با NOT EXISTS بنویس، و بدان (+) در Oracle فقط برای خواندن کد قدیمی است. GROUP BY ردیفها را فرومیپاشد؛ STRING_AGG معادل LISTAGG است و سرریز ORA-01489 یک تلهٔ واقعیِ محیط تولید. CTEها خوانایی و بازگشت میدهند — رفتار materialize در PostgreSQL از ۱۲ عوض شد و در Oracle هزینهمحور است؛ و CONNECT BY میراثی است که باید بتوانی به CTE بازگشتی ترجمهاش کنی. Window functionها (به زبان Oracle: analytic function) رتبه، جمع دونده و مقایسهٔ بینردیفی را بدون فروپاشی میدهند؛ سهگانهٔ رتبهبندی، تفاوت ROWS/RANGE، IGNORE NULLS که فقط Oracle دارد و KEEP DENSE_RANK را بلد باش. در صفحهبندی سهگانهٔ ROWNUM/ROW_NUMBER()/FETCH FIRST و اینکه چرا ROWNUM > 1 هرگز جواب نمیدهد را حفظ کن، و برای صفحات عمیق سراغ keyset pagination برو. در NULL، تلهٔ NOT IN مشترک است ولی «رشتهٔ خالی = NULL» و «DATE ساعت دارد» فقط مالِ Oracleاند. ایندکسها اسکن O(n) را O(log n) میکنند، از قاعدهٔ پیشوند چپترین پیروی میکنند، رایگان نیستند، و در Oracle کلیدهای کاملاً NULL را ذخیره نمیکنند. و بالاخره با EXPLAIN (ANALYZE, BUFFERS) در PostgreSQL و با /*+ GATHER_PLAN_STATISTICS */ بههمراه DBMS_XPLAN.DISPLAY_CURSOR('ALLSTATS LAST') در Oracle حدس را کنار بگذار و شکاف تخمین/واقعیت را بخوان. سه مسئلهٔ طلایی — دومین حقوق، Top-N per group و gaps-and-islands — را در هر دو دیالکت با چشم بسته بنویس. حالا واقعاً آمادهای.
Let us be honest: SQL is the language everyone thinks they know — right up until a "correct" query crawls on a ten-million-row table and someone in an interview asks "why?". That gap is exactly where a junior and a senior engineer part ways.
One note about this edition: every code block here is bilingual. Above each block you get two tabs — PostgreSQL and Oracle — and your choice is remembered site-wide. Why does that matter? Because these are the two engines you are most likely to meet at work, and a senior interviewer almost always keeps a "and how would you do that in Oracle?" question in their pocket. Someone who knows only one gets stuck mid-migration or mid-incident.
PostgreSQL examples target PostgreSQL 16/17; Oracle examples target Oracle Database 19c / 23ai.
We build, step by step: (1) SQL is declarative and execution is a plan, (2) the real logical order a query runs in, (3) DDL/DML/DQL plus the data-type, date and upsert differences between the two engines, (4) every join type and its physical algorithms, (5) GROUP BY/HAVING and STRING_AGG vs LISTAGG, (6) CTEs, recursive CTEs and Oracle's CONNECT BY heritage, (7) window functions and Oracle analytics such as KEEP DENSE_RANK, (8) set operations (EXCEPT vs MINUS), (9) pagination and the ROWNUM / ROW_NUMBER() / FETCH FIRST trio, (10) NULL handling and Oracle's empty-string-is-NULL trap, (11) indexes and the planner, (12) EXPLAIN ANALYZE vs EXPLAIN PLAN/AUTOTRACE, (13) the three classic interview problems, and finally a portability cheat-sheet plus 20 interview questions with full answers.
Part 0 — words you must know
- predicate: any expression evaluating to true/false/unknown, e.g.
salary > 8000. Picture a doorman inspecting each row and saying "pass" or "go back." - set: SQL works on a set of rows, not one row at a time — like handling the whole basket of apples instead of picking them one by one.
- planner / optimizer: the brain that decides how to run your query. PostgreSQL calls it the planner; Oracle calls it the CBO (Cost-Based Optimizer).
- statistics: the statistical summary the engine keeps about each table and guesses from. Refreshed with
ANALYZEin PostgreSQL, withDBMS_STATSin Oracle. - heap: in PostgreSQL, the table file on disk where rows live. Oracle's equivalent is a segment, and each row's physical address is a ROWID (PostgreSQL's is
ctid). - DUAL: Oracle's one-row system table. Because in Oracle (before 23ai) every
SELECTmust have aFROM.
Whenever you meet an unfamiliar term, immediately build a real-world picture for it. The brain keeps pictures and forgets dry definitions.
-- PostgreSQL: FROM is optional
SELECT 1 + 1 AS two, current_date AS today;-- Oracle through 19c: every SELECT needs a FROM; DUAL is the dummy one-row table
SELECT 1 + 1 AS two, TRUNC(SYSDATE) AS today FROM dual;
-- Oracle 23ai onward: FROM is no longer mandatory --> SELECT 1 + 1 AS two;It is a real table owned by SYS with exactly one row and one column (DUMMY VARCHAR2(1)). It exists purely because Oracle's grammar made FROM mandatory. 23ai lifted that requirement, but hundreds of thousands of lines of legacy code still say FROM dual — so you must recognize it.
SQL is declarative, execution is a plan
You walk into a restaurant and say "I'd like a medium steak." You do not say "turn on the stove, heat the pan, sear each side three minutes." You state only the what; the chef decides the how. SQL is exactly this, and the entire game lives in that gap.
Every query compiles into a tree of physical operators, each carrying an estimated cost. Their names differ between the two engines — and this small table does half the work of reading a plan:
| Concept | PostgreSQL | Oracle |
|---|---|---|
| Read the whole table | Seq Scan |
TABLE ACCESS FULL |
| Range walk over an index | Index Scan |
INDEX RANGE SCAN |
| Answer entirely from the index | Index Only Scan |
range scan with no ROWID step |
| Go from index to table | part of Index Scan | TABLE ACCESS BY INDEX ROWID |
| Joins | Hash Join · Nested Loop · Merge Join |
HASH JOIN · NESTED LOOPS · SORT MERGE JOIN |
| Grouped aggregation | HashAggregate · GroupAggregate |
HASH GROUP BY · SORT GROUP BY |
Mastering SQL means switching fluently between two views: the logical model (what the result means) and the physical model (what the database does). A junior stops at the first; a senior holds both and can explain why a "correct" query is slow.
The path a SQL text takes to a result / مسیر یک متن SQL تا نتیجه:
flowchart LR
A["SQL text"] --> B["Parse"]
B --> C["Rewrite"]
C --> D["Optimizer<br/>PG: planner · Oracle: CBO"]
E["Statistics<br/>ANALYZE · DBMS_STATS"] --> D
D --> F["Execution plan"]
F --> G["Executor"]
G --> H["Result set"]
Logical order of evaluation
SQL is not evaluated top-to-bottom as written. You write SELECT first, but the engine runs it nearly last:
FROM / JOIN -> build the working set of rows
WHERE -> filter rows (no aggregates allowed)
GROUP BY -> collapse into groups
HAVING -> filter groups (aggregates allowed)
SELECT -> compute expressions and window functions
DISTINCT · ORDER BY
LIMIT/OFFSET (PostgreSQL) | OFFSET … FETCH FIRST (ANSI and Oracle)
Picture a factory line: raw material arrives from the warehouse (FROM), quality control tosses the bad rows (WHERE), products are sorted into bins (GROUP BY), incomplete bins are removed (HAVING), and only at the end does labeling happen (SELECT).
This order explains the classic gotchas: you cannot use a SELECT alias in WHERE (it does not exist yet); you cannot filter AVG(salary) in WHERE (grouping has not happened — that is what HAVING is for); and window functions run after GROUP BY, so an aggregate may nest inside a window but not the other way around.
"Why does WHERE avg_sal > 5000 error out?" Because at the moment WHERE runs, no groups exist yet. Know the error codes too: PostgreSQL raises SQLSTATE 42803 (grouping_error) and Oracle raises ORA-00934: group function is not allowed here.
PostgreSQL has always allowed a SELECT alias in GROUP BY/ORDER BY (but not in WHERE). Oracle rejected it through 21c with ORA-00904: invalid identifier; from 23ai aliases are allowed in GROUP BY and HAVING, and column positions too (with group_by_position_enabled = TRUE). If your code must run on 19c, repeat the full expression instead of the alias.
DDL / DML / DQL — the three sublanguages
- DDL:
CREATE,ALTER,DROP,TRUNCATE— shaping the schema; the architect of the building. - DML:
INSERT,UPDATE,DELETE,MERGE— moving the contents inside it. - DQL:
SELECT— it only looks and reports.
In PostgreSQL, DDL is transactional: BEGIN; ALTER TABLE …; CREATE INDEX …; ROLLBACK; and it is as if nothing happened. In Oracle, every DDL statement issues an implicit commit before and after itself — so not only is the DDL irreversible, it also force-commits any uncommitted DML you had pending. This is one of the biggest migration shocks in both directions: on Oracle you must write your own compensating script.
The schema we reuse across the whole chapter:
CREATE TABLE department (
id INT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE employee (
id INT PRIMARY KEY,
name TEXT NOT NULL,
dept_id INT REFERENCES department(id),
salary NUMERIC(10,2) NOT NULL,
hired_on DATE NOT NULL
);CREATE TABLE department (
id NUMBER(10) PRIMARY KEY,
name VARCHAR2(100) NOT NULL
);
CREATE TABLE employee (
id NUMBER(10) PRIMARY KEY,
name VARCHAR2(100) NOT NULL,
dept_id NUMBER(10) REFERENCES department(id),
salary NUMBER(10,2) NOT NULL,
hired_on DATE NOT NULL
);TEXT in PostgreSQL is unbounded and carries no performance penalty; Oracle has no such type, so you write VARCHAR2(n) (up to 4000 bytes, or 32767 with MAX_STRING_SIZE = EXTENDED) and CLOB for longer text. NUMERIC(p,s) and NUMBER(p,s) are effectively the same. And the big one: DATE in PostgreSQL is date-only, while DATE in Oracle is date plus time — a trap we unpack a few lines below.
Now the seed data. Note that Oracle has no multi-row VALUES (…),(…) before 23ai:
INSERT INTO department VALUES (1,'Engineering'),(2,'Sales'),(3,'Legal');
INSERT INTO employee VALUES
(1,'Ada', 1, 9000, DATE '2020-01-10'),
(2,'Linus', 1, 9000, DATE '2021-03-01'),
(3,'Grace', 1, 8000, DATE '2019-07-15'),
(4,'Sam', 2, 7000, DATE '2022-02-20'),
(5,'Kai', 2, 7500, DATE '2023-05-05'),
(6,'Uma', NULL, 6000, DATE '2023-06-01'); -- no department yetINSERT INTO department (id, name) VALUES (1,'Engineering');
INSERT INTO department (id, name) VALUES (2,'Sales');
INSERT INTO department (id, name) VALUES (3,'Legal');
-- Through 19c: multi-row inserts use INSERT ALL … SELECT * FROM dual
INSERT ALL
INTO employee VALUES (1,'Ada', 1, 9000, DATE '2020-01-10')
INTO employee VALUES (2,'Linus', 1, 9000, DATE '2021-03-01')
INTO employee VALUES (3,'Grace', 1, 8000, DATE '2019-07-15')
INTO employee VALUES (4,'Sam', 2, 7000, DATE '2022-02-20')
INTO employee VALUES (5,'Kai', 2, 7500, DATE '2023-05-05')
INTO employee VALUES (6,'Uma', NULL, 6000, DATE '2023-06-01')
SELECT * FROM dual;
COMMIT; -- without this, no other session sees the datapsql defaults to autocommit; in SQL*Plus / SQLcl / JDBC against Oracle, a transaction opens on the first DML and stays open until an explicit COMMIT. I have watched teams spend half a day hunting "data that will not save" when the whole problem was a forgotten COMMIT.
Note Uma: her department is NULL. This "employee without a department" keeps coming to our aid. The Legal department has no employees — also deliberate.
Updating one table from another
Here the engines genuinely part ways:
-- PostgreSQL's non-standard but wonderfully convenient UPDATE … FROM
UPDATE employee e
SET salary = salary * 1.10
FROM department d
WHERE e.dept_id = d.id AND d.name = 'Engineering';-- Oracle has no UPDATE … FROM. Two idiomatic options:
UPDATE employee e
SET e.salary = e.salary * 1.10
WHERE EXISTS (SELECT 1 FROM department d
WHERE d.id = e.dept_id AND d.name = 'Engineering');
MERGE INTO employee e
USING (SELECT id FROM department WHERE name = 'Engineering') d
ON (e.dept_id = d.id)
WHEN MATCHED THEN UPDATE SET e.salary = e.salary * 1.10;
COMMIT;If the FROM produces more than one matching row, PostgreSQL silently picks one without an error — the result is non-deterministic and painfully hard to trace later. MERGE raises an error in the same situation (ORA-30926: unable to get a stable set of rows in the source tables), which is the safer behavior. For sensitive logic, prefer MERGE.
Idempotency and upsert
Idempotent means an operation that yields the same result whether you run it once or a hundred times — like a light switch you keep setting to "on".
-- PostgreSQL's native route: ON CONFLICT (the upsert)
INSERT INTO department (id, name) VALUES (3, 'Legal & Compliance')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
-- Since PostgreSQL 15 the standard MERGE is available too
MERGE INTO department d
USING (SELECT 3 AS id, 'Legal & Compliance' AS name) s
ON (d.id = s.id)
WHEN MATCHED THEN UPDATE SET name = s.name
WHEN NOT MATCHED THEN INSERT (id, name) VALUES (s.id, s.name);-- Oracle has no ON CONFLICT — MERGE is the equivalent (since 9i)
MERGE INTO department d
USING (SELECT 3 AS id, 'Legal & Compliance' AS name FROM dual) s
ON (d.id = s.id)
WHEN MATCHED THEN UPDATE SET d.name = s.name
WHEN NOT MATCHED THEN INSERT (d.id, d.name) VALUES (s.id, s.name);
COMMIT;ON CONFLICT keys off a unique constraint and has well-defined, safe behavior under concurrency. MERGE keys off a join condition, can do UPDATE, INSERT and DELETE in one statement, and its source may be a full query — so it is far stronger for batch loads, but it is not inherently atomic against concurrent writers. PostgreSQL 17 added RETURNING and WHEN NOT MATCHED BY SOURCE to MERGE.
The Oracle DATE trap
DATE in Oracle always carries a time component (to second precision). So a row stored as "5 May 2023 at 14:30" is not found by hired_on = DATE '2023-05-05'.
SELECT current_date AS today,
now() AS ts_now,
to_char(hired_on, 'YYYY-MM-DD') AS hired_txt,
hired_on + INTERVAL '90 days' AS probation_end
FROM employee
WHERE hired_on >= DATE '2022-01-01'
AND hired_on < DATE '2023-01-01';SELECT TRUNC(SYSDATE) AS today,
SYSTIMESTAMP AS ts_now,
TO_CHAR(hired_on, 'YYYY-MM-DD') AS hired_txt,
hired_on + 90 AS probation_end -- a number means "days"
FROM employee
WHERE hired_on >= DATE '2022-01-01'
AND hired_on < DATE '2023-01-01';The tempting escape is WHERE TRUNC(hired_on) = DATE '2023-05-05'. It gives the right answer but kills the index (it is no longer sargable) and you land on TABLE ACCESS FULL. The right fix is always the half-open range: >= DATE '2023-05-05' AND < DATE '2023-05-06'. If you truly need TRUNC, build a function-based index on TRUNC(hired_on).
SYSDATE returns the database server clock and is not tied to the transaction. In PostgreSQL, now() and CURRENT_TIMESTAMP return the transaction start time (they stay frozen inside one transaction); for a live reading you need clock_timestamp(). This difference really bites in time-stamped reports and in tests.
NVL / DECODE vs COALESCE / CASE
Oracle had its own functions before ANSI standardized these. Both families work in Oracle today, but legacy code is full of NVL and DECODE:
SELECT name,
COALESCE(dept_id::text, 'unassigned') AS dept,
CASE WHEN salary > 8000 THEN 'senior'
WHEN salary > 7000 THEN 'mid'
ELSE 'junior' END AS band,
NULLIF(salary, 0) AS salary_or_null
FROM employee;SELECT name,
NVL(TO_CHAR(dept_id), 'unassigned') AS dept,
CASE WHEN salary > 8000 THEN 'senior'
WHEN salary > 7000 THEN 'mid'
ELSE 'junior' END AS band,
NULLIF(salary, 0) AS salary_or_null,
DECODE(dept_id, 1, 'Eng', 2, 'Sales', 'Other') AS dept_short, -- legacy style
NVL2(dept_id, 'assigned', 'free') AS assignment_state
FROM employee;COALESCE takes any number of arguments and short-circuits: as soon as it finds the first non-NULL it stops evaluating. NVL takes exactly two and evaluates both — so if the second argument is an expensive subquery you pay for it needlessly. NVL also casts the second argument to the first one's type and can raise ORA-01722: invalid number. Practical rule: write COALESCE and CASE even in new Oracle code.
Identity columns and RETURNING
CREATE TABLE audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
msg TEXT NOT NULL,
logged_at timestamptz NOT NULL DEFAULT now()
);
-- In PostgreSQL, RETURNING produces a real result set
INSERT INTO audit_log (msg) VALUES ('raise applied')
RETURNING id, logged_at;CREATE TABLE audit_log (
id NUMBER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- since 12c
msg VARCHAR2(4000) NOT NULL,
logged_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
-- Oracle has RETURNING too, but only as RETURNING … INTO bind variables
DECLARE
v_id NUMBER;
BEGIN
INSERT INTO audit_log (msg) VALUES ('raise applied') RETURNING id INTO v_id;
COMMIT;
END;
/The classic pattern was a SEQUENCE plus a BEFORE INSERT trigger — you will see it constantly in legacy code. In PostgreSQL, serial is the old spelling and GENERATED … AS IDENTITY is the standard one.
Joins — every type, with the row-math
You have two phone books: one lists employees with their department number, one lists departments with their name. A join places them side by side. The condition saying which row of this matches which row of that is the predicate.
The mental model that never fails: INNER produces the matched intersection; OUTER adds back the unmatched rows from one or both sides, padding the missing side with NULLs.
INNER JOIN LEFT JOIN FULL JOIN
A ∩ B A + (A∩B) A ∪ B
┌───┐ ┌───────┐ ┌───────────┐
│▓▓▓│ │███▓▓▓ │ │███▓▓▓███ │
└───┘ └───────┘ └───────────┘
only matches all A, matched B everything, NULL-padded
INNER JOIN is a party where only people both on the guest list AND actually showing up get in. LEFT JOIN means "let everyone who showed up in, and for those not on the list write department: unknown (NULL)." FULL JOIN means both the uninvited who came and the invited who did not show up all appear in the report.
Good news: the ANSI join syntax is identical in both engines. Remember Uma has no department and Legal has no employees:
-- INNER: employees that HAVE a department (Uma is dropped)
SELECT e.name, d.name AS dept FROM employee e
JOIN department d ON e.dept_id = d.id;
-- LEFT: all employees; Uma appears with dept = NULL
SELECT e.name, d.name AS dept FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;
-- RIGHT: all departments; Legal appears with employee = NULL
SELECT e.name, d.name AS dept FROM employee e
RIGHT JOIN department d ON e.dept_id = d.id;
-- FULL: the union of both
SELECT e.name, d.name AS dept FROM employee e
FULL JOIN department d ON e.dept_id = d.id;-- Oracle has fully supported ANSI join syntax since 9i and recommends it
SELECT e.name, d.name AS dept FROM employee e
JOIN department d ON e.dept_id = d.id;
SELECT e.name, d.name AS dept FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;
SELECT e.name, d.name AS dept FROM employee e
RIGHT JOIN department d ON e.dept_id = d.id;
SELECT e.name, d.name AS dept FROM employee e
FULL JOIN department d ON e.dept_id = d.id;Oracle's legacy (+) syntax
If you work on an older Oracle system you will absolutely meet this. Before ANSI support, Oracle used the (+) operator, placed next to the column of the side that supplies NULLs:
-- PostgreSQL has no (+) operator; ANSI is the only form
SELECT e.name, d.name AS dept FROM employee e
LEFT JOIN department d ON e.dept_id = d.id;SELECT e.name, d.name AS dept
FROM employee e, department d
WHERE e.dept_id = d.id(+); -- equivalent to LEFT JOIN
SELECT e.name, d.name AS dept
FROM employee e, department d
WHERE e.dept_id(+) = d.id; -- equivalent to RIGHT JOINThree reasons: (1) (+) cannot express a FULL OUTER JOIN — marking both sides raises ORA-01468. (2) If you forget (+) on even one predicate touching that table, the join silently degrades to INNER. (3) Oracle's own documentation recommends ANSI syntax because the optimizer has more transformations available for it. Learn (+) only to read legacy code.
CROSS JOIN and the ON-vs-WHERE trap
CROSS JOIN is the Cartesian product — every possible combination (6 employees × 3 departments = 18 rows). Useful for generating grids or calendars, not for ordinary joins.
Now the classic gotcha: on a LEFT JOIN, a predicate in ON filters before the join; the same predicate in WHERE filters after and silently turns the outer join into an inner join.
-- WRONG: behaves like an INNER JOIN, because NULL = 'Sales' is UNKNOWN
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id
WHERE d.name = 'Sales';
-- RIGHT: keep LEFT semantics by putting the condition in ON
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id AND d.name = 'Sales';-- Exactly the same trap and the same behavior in Oracle
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id
WHERE d.name = 'Sales'; -- silently became INNER
SELECT e.name, d.name FROM employee e
LEFT JOIN department d ON e.dept_id = d.id AND d.name = 'Sales';
-- And in the legacy syntax: every predicate on d needs its own (+)
SELECT e.name, d.name
FROM employee e, department d
WHERE e.dept_id = d.id(+) AND d.name(+) = 'Sales';Why? LEFT JOIN first keeps every employee, leaving d.name NULL for the department-less ones. Then WHERE d.name = 'Sales' runs — but NULL = 'Sales' is neither true nor false, it is UNKNOWN, and WHERE discards anything UNKNOWN. All the good LEFT rows you wanted to keep vanish. The result is a hidden INNER JOIN.
Any condition on the nullable side placed in WHERE degrades a LEFT JOIN to an INNER JOIN. The one exception is WHERE d.id IS NULL, which is written deliberately to express an anti-join.
Self-join and anti-join
A self-join joins a table to itself — used when the relationship lives inside the table (employee → manager). An anti-join finds rows with no match:
-- Departments with no employees
SELECT d.name FROM department d
WHERE NOT EXISTS (SELECT 1 FROM employee e WHERE e.dept_id = d.id);
-- Alternative form: LEFT JOIN + IS NULL (usually the same plan)
SELECT d.name FROM department d
LEFT JOIN employee e ON e.dept_id = d.id
WHERE e.id IS NULL;SELECT d.name FROM department d
WHERE NOT EXISTS (SELECT 1 FROM employee e WHERE e.dept_id = d.id);
SELECT d.name FROM department d
LEFT JOIN employee e ON e.dept_id = d.id
WHERE e.id IS NULL;
-- Oracle has one extra tool: MINUS (the EXCEPT equivalent)
SELECT id FROM department
MINUS
SELECT dept_id FROM employee WHERE dept_id IS NOT NULL;If the subquery yields even one NULL, col NOT IN (…) collapses to UNKNOWN for every row and you get zero rows — identically in PostgreSQL and Oracle. The definitive fix is NOT EXISTS. If you must use NOT IN, put WHERE x IS NOT NULL inside the subquery.
Join algorithms
| Algorithm | How it works | Best when | PostgreSQL / Oracle |
|---|---|---|---|
| Nested loop | For each outer row, probe the inner side | One side small, inner indexed | Nested Loop / NESTED LOOPS |
| Hash | Build a hash table on the smaller side, probe with the larger | Large, unsorted, equality join | Hash Join / HASH JOIN |
| Sort-merge | Sort both inputs, walk in lockstep | Both already sorted / indexed on the key | Merge Join / SORT MERGE JOIN |
For each name in a list you want its phone number from a directory. Nested loop: for each name, go through the directory. Hash: first build a helper sheet mapping names to numbers, then read your list once. Sort-merge: if both are alphabetically sorted, put a finger on each and slide down together.
And if you want to push the planner onto a different path, you meet two completely different philosophies:
-- PostgreSQL ships no hints; its philosophy is "fix the optimizer, not the plan"
SET enable_hashjoin = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT e.name, d.name FROM employee e JOIN department d ON d.id = e.dept_id;
RESET enable_hashjoin;
-- (the pg_hint_plan extension adds Oracle-style comment hints if installed)-- Oracle has a full, documented hint system
SELECT /*+ LEADING(d e) USE_HASH(e) FULL(e) */ e.name, d.name
FROM employee e JOIN department d ON d.id = e.dept_id;
SELECT /*+ INDEX(e ix_emp_dept_sal) */ e.name, e.salary
FROM employee e WHERE e.dept_id = 1;A hint is a painkiller, not a cure. In Oracle a hint freezes the plan, and when data volume or statistics change that same hint becomes the cause of the slowdown. The more durable answer in Oracle is a SQL Plan Baseline (via DBMS_SPM), which pins a good plan but lets a better one be verified and promoted. In PostgreSQL, the correct move is to fix the statistics (ANALYZE, CREATE STATISTICS) rather than force the plan.
Aggregation: GROUP BY and HAVING
Sometimes you do not want detail, you want a summary: "what is the average salary per department?" GROUP BY collapses several rows into one summary row. The key distinction: HAVING filters groups; WHERE filters rows before grouping.
SELECT d.name,
COUNT(*) AS headcount,
AVG(e.salary) AS avg_salary,
MAX(e.salary) AS top_salary
FROM employee e JOIN department d ON d.id = e.dept_id
WHERE e.hired_on >= DATE '2021-01-01' -- filter rows first
GROUP BY d.name
HAVING AVG(e.salary) > 7500; -- then filter groupsSELECT d.name,
COUNT(*) AS headcount,
AVG(e.salary) AS avg_salary,
MAX(e.salary) AS top_salary
FROM employee e JOIN department d ON d.id = e.dept_id
WHERE e.hired_on >= DATE '2021-01-01'
GROUP BY d.name
HAVING AVG(e.salary) > 7500;Subtleties interviewers ask verbatim: COUNT(*) counts every row, COUNT(col) skips NULLs, COUNT(DISTINCT col) dedups. Every non-aggregated SELECT column must appear in GROUP BY — violating that raises SQLSTATE 42803 in PostgreSQL and ORA-00979: not a GROUP BY expression in Oracle. And GROUPING SETS/ROLLUP/CUBE produce subtotals and grand totals in one pass — both engines have all three.
The earlier you drop rows, the better. WHERE runs before GROUP BY, so put any condition that does not need an aggregate there. Reserve HAVING only for conditions that truly need an aggregated value.
String aggregation: STRING_AGG vs LISTAGG
The function name differs between engines, and this is one of the most frequent "how do they differ" interview questions:
SELECT d.name AS dept,
COUNT(*) AS headcount,
STRING_AGG(e.name, ', ' ORDER BY e.salary DESC) AS members
FROM employee e JOIN department d ON d.id = e.dept_id
GROUP BY d.name;SELECT d.name AS dept,
COUNT(*) AS headcount,
-- WITHIN GROUP ordering is mandatory; ON OVERFLOW prevents ORA-01489
LISTAGG(e.name, ', ' ON OVERFLOW TRUNCATE '…' WITH COUNT)
WITHIN GROUP (ORDER BY e.salary DESC) AS members
FROM employee e JOIN department d ON d.id = e.dept_id
GROUP BY d.name;LISTAGG returns a VARCHAR2 capped at 4000 bytes (or 32767 with MAX_STRING_SIZE = EXTENDED). Let one group grow and production suddenly throws ORA-01489: result of string concatenation is too long — and the worst part is that it never fires on test data. The ON OVERFLOW TRUNCATE clause (since 12.2) tames it. STRING_AGG in PostgreSQL has no such cap (its type is text). Also, DISTINCT inside LISTAGG only arrived in 19c, while PostgreSQL always had it.
Conditional aggregation: FILTER vs CASE
-- PostgreSQL has the standard FILTER clause; the most readable form there is
SELECT dept_id,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE salary > 8000) AS high_earners,
SUM(salary) FILTER (WHERE hired_on >= DATE '2022-01-01') AS recent_payroll
FROM employee GROUP BY dept_id;-- Oracle has no FILTER clause — CASE inside the aggregate is the exact equivalent
SELECT dept_id,
COUNT(*) AS total,
COUNT(CASE WHEN salary > 8000 THEN 1 END) AS high_earners,
SUM(CASE WHEN hired_on >= DATE '2022-01-01'
THEN salary END) AS recent_payroll
FROM employee GROUP BY dept_id;Because COUNT does not count NULLs. With ELSE 0 the zeros get counted too and the answer is wrong. Without ELSE, CASE defaults to NULL and skips exactly the rows you wanted skipped. The pattern works in both engines and is the basis of "pivot" aggregation.
Subqueries vs CTEs
A subquery is a nested SELECT — handy, but once they nest deeply, reading them is like peeling a many-layered onion. A CTE (WITH clause) is a named subquery declared up front — like writing "base sauce = such and such" at the top of a recipe. It is more readable, reusable within the statement, and the standard way to write recursion. Oracle calls it subquery factoring and has had it since 9i:
WITH dept_stats AS (
SELECT dept_id, AVG(salary) AS avg_sal FROM employee GROUP BY dept_id
)
SELECT e.name, e.salary, s.avg_sal
FROM employee e
JOIN dept_stats s ON s.dept_id = e.dept_id
WHERE e.salary > s.avg_sal; -- earns above their department average-- Oracle calls this "subquery factoring"; the syntax is identical
WITH dept_stats AS (
SELECT dept_id, AVG(salary) AS avg_sal FROM employee GROUP BY dept_id
)
SELECT e.name, e.salary, s.avg_sal
FROM employee e
JOIN dept_stats s ON s.dept_id = e.dept_id
WHERE e.salary > s.avg_sal;CTE materialization
Two words first. To materialize means to compute a subquery's result once, in full, into a temporary table and read from that (like cooking the whole base sauce ahead). An optimization fence is a wall stopping the planner from pushing outer conditions down into the subquery — that pushing is called predicate push-down.
Before PostgreSQL 12, a CTE was an unconditional fence. Since 12, a CTE that is referenced once and is side-effect-free is inlined by default. Oracle never had such a syntactic rule — the CBO decides on cost, and you steer it with two hints:
WITH recent AS MATERIALIZED ( -- force the fence
SELECT * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;
-- And the reverse: NOT MATERIALIZED forces inlining even for multi-reference CTEs
WITH recent AS NOT MATERIALIZED (
SELECT * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;-- Oracle has no MATERIALIZED keyword; two hints do the same job
WITH recent AS (
SELECT /*+ MATERIALIZE */ * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;
WITH recent AS (
SELECT /*+ INLINE */ * FROM employee WHERE hired_on >= DATE '2022-01-01'
)
SELECT * FROM recent WHERE dept_id = 2;These two hints are not listed in Oracle's official documentation, yet they have worked since the 1990s and are an industry standard in practice. When Oracle materializes a CTE it effectively builds an internal global temporary table, and the plan shows TEMP TABLE TRANSFORMATION and LOAD AS SELECT.
Recursive CTEs
Some data is tree-shaped: an org chart, a category hierarchy, a bill-of-materials. A recursive CTE has two parts joined by UNION ALL: an anchor (the base case) and a recursive term that references the CTE itself and advances one step each time.
The tree we are about to walk / درختی که میپیماییم:
flowchart TD
CEO["CEO (1)"] --> CTO["CTO (2)"]
CTO --> VP["VP Eng (3)"]
VP --> A["Dev A (4)"]
VP --> B["Dev B (5)"]
CREATE TABLE emp_tree (id INT PRIMARY KEY, name TEXT, manager_id INT);
INSERT INTO emp_tree VALUES
(1,'CEO',NULL),(2,'CTO',1),(3,'VP Eng',2),(4,'Dev A',3),(5,'Dev B',3);
WITH RECURSIVE chain AS (
SELECT id, name, manager_id, 1 AS depth
FROM emp_tree WHERE id = 4 -- anchor: start at Dev A
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1
FROM emp_tree e
JOIN chain c ON e.id = c.manager_id -- climb toward the CEO
)
SELECT * FROM chain; -- Dev A -> VP Eng -> CTO -> CEOCREATE TABLE emp_tree (id NUMBER(10) PRIMARY KEY, name VARCHAR2(50), manager_id NUMBER(10));
INSERT ALL
INTO emp_tree VALUES (1,'CEO',NULL) INTO emp_tree VALUES (2,'CTO',1)
INTO emp_tree VALUES (3,'VP Eng',2) INTO emp_tree VALUES (4,'Dev A',3)
INTO emp_tree VALUES (5,'Dev B',3)
SELECT * FROM dual;
COMMIT;
-- Oracle has no RECURSIVE keyword, but the column alias list is mandatory
WITH chain (id, name, manager_id, depth) AS (
SELECT id, name, manager_id, 1 FROM emp_tree WHERE id = 4
UNION ALL
SELECT e.id, e.name, e.manager_id, c.depth + 1
FROM emp_tree e JOIN chain c ON e.id = c.manager_id
)
SELECT * FROM chain;(1) Oracle has no RECURSIVE keyword — writing it is a syntax error; recursion is detected purely from the CTE referencing itself. (2) Oracle requires the CTE column alias list for the recursive form; omit it and you get ORA-32039: recursive WITH clause must have column alias list. In PostgreSQL both rules are inverted.
The anchor says "stand on the Dev A rung." The recursive term says "step up one rung to your manager, and repeat until no manager remains." Each iteration bumps depth by one so you know how many rungs you climbed.
UNION ALL (not UNION) is required for the recursion to accumulate. To guard against cycles in dirty data, both engines have the standard SEARCH and CYCLE clauses — PostgreSQL since 14, Oracle since 11gR2. Just remember PostgreSQL writes CYCLE id SET is_cycle USING path (a boolean column plus a path column) while Oracle writes CYCLE id SET is_cycle TO 'Y' DEFAULT 'N' (two text values, no path column).
CONNECT BY — the Oracle heritage that is still everywhere
Long before recursive CTEs were standardized, Oracle had its own syntax from the 1980s. It is alive in millions of lines of enterprise code:
-- PostgreSQL has no CONNECT BY; you build the pseudo-columns yourself
WITH RECURSIVE t AS (
SELECT id, name, manager_id,
1 AS lvl, -- the LEVEL equivalent
name AS root_name, -- the CONNECT_BY_ROOT equivalent
' -> ' || name AS path -- the SYS_CONNECT_BY_PATH equivalent
FROM emp_tree WHERE manager_id IS NULL
UNION ALL
SELECT e.id, e.name, e.manager_id,
t.lvl + 1, t.root_name, t.path || ' -> ' || e.name
FROM emp_tree e JOIN t ON e.manager_id = t.id
)
SELECT lvl, LPAD(' ', (lvl-1)*2) || name AS org, path FROM t ORDER BY path;-- The legacy Oracle syntax: far more compact, with ready-made pseudo-columns
SELECT LEVEL AS lvl,
LPAD(' ', (LEVEL-1)*2) || name AS org,
CONNECT_BY_ROOT name AS root_name,
SYS_CONNECT_BY_PATH(name, ' -> ') AS path,
CONNECT_BY_ISLEAF AS is_leaf
FROM emp_tree
START WITH manager_id IS NULL -- the anchor
CONNECT BY NOCYCLE PRIOR id = manager_id -- the recursive step; PRIOR = parent row
ORDER SIBLINGS BY name;START WITH ⇔ the anchor member · CONNECT BY PRIOR parent = child ⇔ the recursive member's join condition · LEVEL ⇔ a depth column you build with +1 · SYS_CONNECT_BY_PATH ⇔ string concatenation you accumulate · CONNECT_BY_ROOT ⇔ a column carried unchanged from the anchor · NOCYCLE ⇔ the CYCLE clause · ORDER SIBLINGS BY ⇔ SEARCH DEPTH FIRST BY … SET ord then ORDER BY ord.
CONNECT BY is Oracle-only, very compact, and usually a touch faster for simple traversals because it has a dedicated row source and gives you LEVEL, CONNECT_BY_ISLEAF and SYS_CONNECT_BY_PATH for free. The recursive CTE is ANSI standard, works in PostgreSQL/SQL Server/MySQL 8+, and is more flexible for complex logic (accumulating several columns, aggregating during traversal). Practical advice: write recursive CTEs even in new Oracle code unless you specifically need one of the pseudo-columns. Oracle has had both since 11gR2; PostgreSQL only has the recursive CTE.
Window functions — aggregates that keep the rows
Oracle has had these since 8i (1999) and calls them analytic functions — so if an interviewer says "analytic function," they mean exactly window functions.
The problem with GROUP BY was that it collapsed rows. But sometimes you want both: each row's detail and a group computation beside it.
Imagine sitting on a train, looking out. The "window" is the slice of scenery you see at any moment, and it moves as the train moves. A window function likewise sees, for each row, a window of related rows and computes over them — but the row itself stays put.
Syntax: func(...) OVER (PARTITION BY … ORDER BY … frame).
SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dense,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg,
ROUND(100.0 * salary / SUM(salary) OVER (PARTITION BY dept_id), 1) AS pct
FROM employee;SELECT name, dept_id, salary,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn,
RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rnk,
DENSE_RANK() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS dense,
AVG(salary) OVER (PARTITION BY dept_id) AS dept_avg,
-- RATIO_TO_REPORT is Oracle-only and gives the same ratio directly
ROUND(100 * RATIO_TO_REPORT(salary) OVER (PARTITION BY dept_id), 1) AS pct
FROM employee;The ranking trio is a favorite interview gotcha. With salaries 9000, 9000, 8000 in one partition:
| Function | Output | Behavior on ties |
|---|---|---|
ROW_NUMBER |
1, 2, 3 | Always unique; arbitrary tiebreak |
RANK |
1, 1, 3 | Ties share rank; gaps after |
DENSE_RANK |
1, 1, 2 | Ties share rank; no gaps |
Two runners finish first at the same time. ROW_NUMBER is a judge forced to write one as 1st and one as 2nd. RANK is the real race: both get gold (rank 1), but the next runner is rank 3. DENSE_RANK is kinder: both are rank 1, and the next is rank 2.
LAG / LEAD and the IGNORE NULLS difference
SELECT name, hired_on, salary,
LAG(salary) OVER (ORDER BY hired_on) AS prev_salary,
LEAD(salary) OVER (ORDER BY hired_on) AS next_salary,
salary - LAG(salary) OVER (ORDER BY hired_on) AS delta
FROM employee;
-- Note: PostgreSQL 17 has no IGNORE NULLS clause; behavior is always RESPECT NULLS.SELECT name, hired_on, salary,
LAG(salary) OVER (ORDER BY hired_on) AS prev_salary,
LEAD(salary) OVER (ORDER BY hired_on) AS next_salary,
salary - LAG(salary) OVER (ORDER BY hired_on) AS delta,
-- Oracle has had IGNORE NULLS from the start: fetch the last non-NULL value
LAG(salary IGNORE NULLS) OVER (ORDER BY hired_on) AS prev_non_null
FROM employee;The "carry the last known value forward" pattern (for example the last recorded price as of this date) is one line in Oracle: LAST_VALUE(price IGNORE NULLS). PostgreSQL 17 lacks the clause, so you must emulate it — e.g. MAX(price) FILTER (WHERE price IS NOT NULL) OVER (ORDER BY d ROWS UNBOUNDED PRECEDING). If you are migrating Oracle to PostgreSQL, this is one of the most frequent rewrites.
Frames and running totals
A "frame" means exactly which rows fall inside the current row's window. An ORDER BY in a window implies a default frame of RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — and this default is identical in both engines, and equally dangerous in both. ROWS counts physical rows, but RANGE lumps peers (rows with the same ORDER BY value) into one frame boundary:
SELECT name, hired_on, salary,
SUM(salary) OVER (ORDER BY hired_on
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
AVG(salary) OVER (ORDER BY hired_on
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3,
SUM(salary) OVER (ORDER BY salary) AS range_default
FROM employee;SELECT name, hired_on, salary,
SUM(salary) OVER (ORDER BY hired_on
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total,
AVG(salary) OVER (ORDER BY hired_on
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS moving_avg_3,
SUM(salary) OVER (ORDER BY salary) AS range_default
FROM employee;SUM(salary) OVER (ORDER BY salary) with no explicit frame means RANGE. Ada and Linus both earn 9000, so they are peers and both get the same number, which includes both of them. If you expected a row-by-row running total, your answer is wrong. Rule: whenever you want a running total, spell out ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — in both engines.
Oracle's analytic heritage: KEEP DENSE_RANK
Oracle has a beautiful tool PostgreSQL has no direct equivalent for: "within each group, give me the value of column X from the row where column Y is highest/lowest" — with no extra subquery:
-- PostgreSQL has no KEEP clause. Two common equivalents:
SELECT dept_id,
MAX(salary) AS top_salary,
(ARRAY_AGG(name ORDER BY salary DESC, id))[1] AS top_earner
FROM employee GROUP BY dept_id;
-- DISTINCT ON — PostgreSQL-only, for "one top row per group"
SELECT DISTINCT ON (dept_id) dept_id, name AS top_earner, salary
FROM employee ORDER BY dept_id, salary DESC, id;-- Oracle: FIRST/LAST with KEEP (DENSE_RANK …) — one pass, no subquery
SELECT dept_id,
MAX(salary) AS top_salary,
MAX(name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC, id) AS top_earner,
MAX(name) KEEP (DENSE_RANK LAST ORDER BY salary DESC, id) AS bottom_earner
FROM employee GROUP BY dept_id;First, you will see it constantly in real Oracle code and without it financial reports become unreadable. Second, it is the first-class answer to "write top-1 per group without a window function." Third, it is usually faster than the "subquery with ROW_NUMBER then filter rn = 1" pattern because it needs only one pass over the data. The outer MAX(name) exists only to disambiguate when several rows tie.
Anywhere you need ranking, running totals, or row-to-row comparison, reach for a window function before a self-join. It is clearer, usually faster (one pass instead of O(n²) comparisons), and far less prone to logical error.
Set operations
UNION removes duplicates (a costly distinct pass); UNION ALL keeps them (cheaper); INTERSECT is the intersection; and EXCEPT/MINUS is the difference.
SELECT id FROM employee WHERE dept_id = 1
EXCEPT
SELECT id FROM employee WHERE salary < 8500; -- Eng employees earning >= 8500
-- PostgreSQL also has the ALL variants (which preserve duplicate counts)
SELECT dept_id FROM employee EXCEPT ALL SELECT id FROM department;-- The historical name of this operator in Oracle is MINUS
SELECT id FROM employee WHERE dept_id = 1
MINUS
SELECT id FROM employee WHERE salary < 8500;
-- Since Oracle 21c, EXCEPT was added as a synonym for MINUS,
-- along with MINUS ALL / EXCEPT ALL / INTERSECT ALL
SELECT id FROM employee WHERE dept_id = 1
EXCEPT
SELECT id FROM employee WHERE salary < 8500;EXCEPT does not exist and you get a syntax error — you must write MINUS. Also, on 19c only UNION has an ALL variant; MINUS ALL and INTERSECT ALL arrived in 21c. This is exactly the kind of detail that breaks ported code in production.
Pagination: ROWNUM, ROW_NUMBER() and FETCH FIRST
Probably the most frequent practical "how do they differ" interview question. PostgreSQL has the non-standard LIMIT … OFFSET …. Oracle does not — through 11g it only had ROWNUM, and since 12c it has the ANSI row-limiting clause OFFSET … FETCH FIRST/NEXT … (which PostgreSQL understands too):
SELECT id, name, salary FROM employee
ORDER BY salary DESC, id
LIMIT 10 OFFSET 20;
-- The ANSI form, understood by PostgreSQL since 8.4 (and more portable)
SELECT id, name, salary FROM employee
ORDER BY salary DESC, id
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;-- Oracle has no LIMIT at all. Since 12c, the standard form:
SELECT id, name, salary FROM employee
ORDER BY salary DESC, id
OFFSET 20 ROWS FETCH FIRST 10 ROWS ONLY;
-- And if the last row's ties must come along (both engines support this):
SELECT id, name, salary FROM employee
ORDER BY salary DESC
FETCH FIRST 3 ROWS WITH TIES;ROWNUM and the mother of all traps
ROWNUM is an Oracle pseudo-column assigned as a row passes the filter — that is, before ORDER BY:
-- PostgreSQL has no ROWNUM-like pseudo-column; the logical equivalent just works
SELECT name, salary FROM employee ORDER BY salary DESC LIMIT 3;
-- And if you want a per-page row number:
SELECT * FROM (
SELECT t.*, ROW_NUMBER() OVER (ORDER BY salary DESC, id) AS rn FROM employee t
) x WHERE rn BETWEEN 21 AND 30;-- WRONG: ROWNUM is assigned before ORDER BY
-- -> three arbitrary rows are taken, and only those three get sorted
SELECT name, salary FROM employee WHERE ROWNUM <= 3 ORDER BY salary DESC;
-- The classic correct form (pre-12c): sort in an inline view, apply ROWNUM outside
SELECT * FROM (SELECT name, salary FROM employee ORDER BY salary DESC)
WHERE ROWNUM <= 3;
-- The modern correct form (12c+):
SELECT name, salary FROM employee ORDER BY salary DESC FETCH FIRST 3 ROWS ONLY;ROWNUM is assigned to a row only when that row passes the filter. The first candidate gets ROWNUM = 1; the predicate ROWNUM > 1 rejects it, so the row is discarded and the counter never advances. The next row is again candidate ROWNUM = 1 — and so on to the end of the table. Result: zero rows, forever. This is exactly why old Oracle pagination always needed two nested layers.
OFFSET 100000 means the engine really produces a hundred thousand rows and throws them away. Cost grows linearly with page number. The professional answer is keyset pagination (the seek method): instead of "10 rows after row 100000", say "10 rows after this specific key value". With an index on the ordering columns, cost becomes constant.
-- PostgreSQL supports multi-column row-value comparison directly
SELECT id, name, salary FROM employee
WHERE (salary, id) < (7500, 5) -- the previous page's key, from the client
ORDER BY salary DESC, id DESC
FETCH FIRST 10 ROWS ONLY;-- Oracle supports row-value comparison only for = and IN, not for < and >
-- so you must expand the predicate by hand:
SELECT id, name, salary FROM employee
WHERE salary < 7500
OR (salary = 7500 AND id < 5)
ORDER BY salary DESC, id DESC
FETCH FIRST 10 ROWS ONLY;The expanded Oracle form is not only longer — the optimizer also has a harder time turning it into a clean INDEX RANGE SCAN because of the OR in the middle. If you genuinely need fast deep pagination on Oracle, build a composite index on (salary DESC, id DESC) and verify with DBMS_XPLAN that the range scan is actually used.
NULL — three-valued logic, and Oracle's empty-string trap
NULL is not a value; it is "unknown". NULL = NULL is UNKNOWN, not true — in both engines. But there is one difference that belongs to Oracle alone:
-- PostgreSQL: the empty string is a real zero-length value, not NULL
SELECT CASE WHEN '' IS NULL THEN 'empty string IS NULL'
ELSE 'empty string is NOT null' END AS truth,
LENGTH('') AS len;
-- prints: empty string is NOT null | 0-- Oracle: the empty string and NULL are the same thing (for VARCHAR2)
SELECT CASE WHEN '' IS NULL THEN 'empty string IS NULL'
ELSE 'empty string is NOT null' END AS truth,
LENGTH('') AS len
FROM dual;
-- prints: empty string IS NULL | (NULL) -- even LENGTH('') is NULL, not zeroThree practical consequences: (1) in Oracle you cannot fill a NOT NULL column with an empty string — you get ORA-01400: cannot insert NULL. (2) WHERE name = '' in Oracle always returns zero rows; you must write IS NULL. (3) In PostgreSQL the two states are completely distinct and your application logic may behave differently for each. If your code must run on both, never rely on the difference between '' and NULL, and normalize input before storing it.
-- PostgreSQL has the standard IS [NOT] DISTINCT FROM: it never yields UNKNOWN
SELECT a.name, b.name FROM employee a JOIN employee b
ON a.dept_id IS NOT DISTINCT FROM b.dept_id -- two NULLs count as "equal"
AND a.id < b.id;
SELECT name, dept_id FROM employee ORDER BY dept_id ASC NULLS FIRST;-- Oracle has no IS [NOT] DISTINCT FROM. Two idiomatic equivalents:
-- 1) DECODE treats two NULLs as equal (a documented DECODE special case)
SELECT a.name, b.name FROM employee a JOIN employee b
ON DECODE(a.dept_id, b.dept_id, 0, 1) = 0 AND a.id < b.id;
-- 2) The explicit, portable form
SELECT a.name, b.name FROM employee a JOIN employee b
ON (a.dept_id = b.dept_id OR (a.dept_id IS NULL AND b.dept_id IS NULL))
AND a.id < b.id;
SELECT name, dept_id FROM employee ORDER BY dept_id ASC NULLS FIRST;Both treat NULL as the largest value, so ORDER BY col ASC defaults to NULLS LAST and DESC defaults to NULLS FIRST (unlike MySQL and SQL Server, which sort NULLs smallest). Still, always be explicit in production code — both for readability and because an index can only supply the ordering for free when its NULL placement matches the query.
LNNVL(condition) returns true when the condition is false or UNKNOWN. Its main use is exactly the NOT IN-with-NULL trap: WHERE LNNVL(dept_id = 1) returns both rows with dept_id <> 1 and rows with dept_id IS NULL. The PostgreSQL equivalent is WHERE dept_id IS DISTINCT FROM 1.
Indexes and the planner
You have a thousand-page book and want every place the word "heart" appears. Without an index you read the whole book; with an alphabetical index at the back you jump straight to "H". An index is exactly that: an auxiliary sorted structure turning an O(n) scan into an O(log n) lookup. The default in both engines is a B-tree (in Oracle, more precisely a B*-tree, but the practical behavior is the same), and it is great for equality, ranges, IN, sorting, and prefix LIKE 'abc%'.
It lets you jump straight to "Miller" (equality), read all of "M through N" in sequence (range), or find all "Mill..." entries (prefix). But if you want everyone whose first name is Ali, a book sorted by surname is useless. That is exactly the reason for the next rule.
Composite indexes and the leftmost-prefix rule
CREATE INDEX ix_emp_dept_sal ON employee (dept_id, salary);
SELECT * FROM employee WHERE dept_id = 1 AND salary > 8000; -- fully served
SELECT * FROM employee WHERE dept_id = 1 ORDER BY salary DESC; -- free ordering
SELECT * FROM employee WHERE salary > 8000; -- cannot be servedCREATE INDEX ix_emp_dept_sal ON employee (dept_id, salary);
SELECT * FROM employee WHERE dept_id = 1 AND salary > 8000;
SELECT * FROM employee WHERE dept_id = 1 ORDER BY salary DESC;
-- Here Oracle may pick INDEX FAST FULL SCAN (reading the whole index like a
-- narrow table) — better than TABLE ACCESS FULL, still far worse than a real
-- INDEX RANGE SCAN
SELECT * FROM employee WHERE salary > 8000;The practical rule: equality columns first, the range/sort column last.
A directory sorted first by city, then by surname within each city. Finding "Tehran / Miller" is easy. Finding all "Miller"s across the whole country? Hard, because they are sorted separately inside each city and scattered. The left column is the entry key.
In Oracle, a B-tree index does not store rows where all key columns are NULL. Consequence: WHERE dept_id IS NULL will never use a single-column index and lands on TABLE ACCESS FULL. The classic fix is to make the index composite ((dept_id, id)) so the key is never entirely NULL. PostgreSQL does the opposite — it stores NULLs and can answer IS NULL with an Index Scan. On large tables with optional columns this difference is decisive.
Covering indexes and index-only scans
If an index contains every column a query touches, the answer can be produced without touching the table:
-- Since PostgreSQL 11: non-key payload columns ride along via INCLUDE
-- (they are not part of the sort key, just carried on the leaf pages)
CREATE INDEX ix_cover ON employee (dept_id, salary) INCLUDE (name);
EXPLAIN (ANALYZE, BUFFERS)
SELECT dept_id, salary, name FROM employee WHERE dept_id = 1;
-- look for "Index Only Scan" and "Heap Fetches: 0" in the plan-- Oracle has no INCLUDE clause: the extra column simply joins the key
CREATE INDEX ix_cover ON employee (dept_id, salary, name);
SELECT /*+ GATHER_PLAN_STATISTICS */ dept_id, salary, name
FROM employee WHERE dept_id = 1;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
-- the sign of covering: no TABLE ACCESS BY INDEX ROWID step in the planPostgreSQL keeps old row versions inside the table itself, and the index cannot know whether a row is visible to your transaction. So even in an Index Only Scan it must consult the visibility map, and if a page is not marked all-visible it falls back to the heap (Heap Fetches > 0); the cure is VACUUM. Oracle does not have this problem because old versions live separately in the undo tablespace. If a PostgreSQL index-only scan is not fast, the first things to check are Heap Fetches and autovacuum health.
Partial indexes
-- PostgreSQL has native partial indexes: only matching rows are stored
CREATE INDEX ix_active_hi ON employee (salary)
WHERE hired_on >= DATE '2022-01-01';
SELECT * FROM employee
WHERE hired_on >= DATE '2022-01-01' AND salary > 7000;-- Oracle has no such thing. The standard trick: a function-based index that
-- returns NULL for unwanted rows — and since a B-tree does not store all-NULL
-- keys, the index effectively becomes partial.
CREATE INDEX ix_active_hi ON employee
(CASE WHEN hired_on >= DATE '2022-01-01' THEN salary END);
-- The query must use the *identical* expression to hit the index
SELECT * FROM employee
WHERE (CASE WHEN hired_on >= DATE '2022-01-01' THEN salary END) > 7000;Oracle has had a feature called partial index since 12c, but it means "build the index only on some partitions of a partitioned table" (INDEXING ON/OFF at partition level) — not PostgreSQL's predicate-based partial index. If an Oracle interviewer uses the phrase, they most likely mean partitioning.
When indexes are ignored
Sargable (Search ARGument ABLE) means a predicate the index can work on directly. The planner may (correctly) skip an index when the predicate is not sargable (a function on the column), when you have a leading wildcard (LIKE '%ada'), when the query returns a large fraction of the table, when statistics are stale, or when an implicit type conversion occurred.
-- The cure: an expression index
CREATE INDEX ix_emp_lname ON employee (lower(name));
SELECT * FROM employee WHERE lower(name) = 'ada';
-- For substring search ('%ada%') a B-tree is useless; you need trigrams
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX ix_emp_name_trgm ON employee USING gin (name gin_trgm_ops);-- The same cure: a function-based index (since 8i)
CREATE INDEX ix_emp_lname ON employee (LOWER(name));
SELECT * FROM employee WHERE LOWER(name) = 'ada';
-- For substring search you need Oracle Text:
-- CREATE INDEX ix_name_txt ON employee(name) INDEXTYPE IS CTXSYS.CONTEXT;
-- Oracle's golden experiment tool: make the index invisible instead of dropping it
ALTER INDEX ix_emp_lname INVISIBLE; -- optimizer ignores it, but it stays maintained
ALTER INDEX ix_emp_lname VISIBLE; -- instant rollbackIn Oracle, mark it INVISIBLE and watch for a few days; the index stays intact so reverting is instant. PostgreSQL has no INVISIBLE, but it has a beautiful trick that falls straight out of transactional DDL: BEGIN; DROP INDEX ix_x; EXPLAIN (ANALYZE) SELECT …; ROLLBACK; — you see the plan without the index and then everything comes back. (Just know DROP INDEX takes a heavy lock, so do not do this on a busy production system.)
Every extra index slows writes and bloats the table. In PostgreSQL, pg_stat_user_indexes shows which indexes were never scanned (idx_scan = 0). In Oracle since 12.2, DBA_INDEX_USAGE does the same job (older releases used ALTER INDEX … MONITORING USAGE and V$OBJECT_USAGE).
Statistics — the planner's fuel
ANALYZE employee;
ALTER TABLE employee ALTER COLUMN dept_id SET STATISTICS 500;
-- Multi-column statistics for correlated columns
CREATE STATISTICS st_emp (dependencies, ndistinct) ON dept_id, salary FROM employee;
ANALYZE employee;
SELECT relname, n_live_tup, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'employee';BEGIN
DBMS_STATS.GATHER_TABLE_STATS(
ownname => USER,
tabname => 'EMPLOYEE',
method_opt => 'FOR ALL COLUMNS SIZE AUTO', -- automatic histograms
estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE,
cascade => TRUE); -- include indexes
END;
/
-- Oracle's answer to CREATE STATISTICS: extended statistics
SELECT DBMS_STATS.CREATE_EXTENDED_STATS(USER,'EMPLOYEE','(dept_id, salary)') FROM dual;
SELECT table_name, num_rows, last_analyzed FROM user_tables
WHERE table_name = 'EMPLOYEE';When the planner thinks 10 rows come back but 10 million actually do, it picks a nested loop and the query runs for hours. PostgreSQL refreshes via autoanalyze, but on very large tables the thresholds arrive late; Oracle has a nightly job that may not finish inside the maintenance window. After any bulk load, refresh statistics manually — do not leave it to luck.
Reading EXPLAIN: two engines, two cultures
PostgreSQL gives you everything in one statement; Oracle has a two-step ritual you must know:
-- EXPLAIN alone = the estimated plan, without running anything
EXPLAIN SELECT e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;
-- EXPLAIN ANALYZE = actually runs it and reports real timings and row counts
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;-- (a) estimated plan only, no execution
EXPLAIN PLAN FOR
SELECT e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(format => 'ALL'));
-- (b) the real EXPLAIN ANALYZE equivalent: run with row-source stats, then read them
SELECT /*+ GATHER_PLAN_STATISTICS */ e.name, d.name FROM employee e
JOIN department d ON d.id = e.dept_id WHERE e.salary > 8000;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'));
-- (c) the quick way in SQL*Plus / SQLcl
SET AUTOTRACE ONEXPLAIN PLAN FOR does not execute the query, so it never sees actual bind values and may show a plan that differs from the real one (because of bind peeking and adaptive plans). This is one of the biggest beginner mistakes in Oracle — hours spent studying a plan that never ran. Whenever you can execute the query, prefer GATHER_PLAN_STATISTICS + DISPLAY_CURSOR('ALLSTATS LAST'); that combination is the true equivalent of EXPLAIN ANALYZE.
Sample PostgreSQL output:
Hash Join (cost=1.07..2.20 rows=3 width=64)
(actual time=0.030..0.041 rows=3 loops=1)
Hash Cond: (e.dept_id = d.id)
Buffers: shared hit=4
-> Seq Scan on employee e (cost=0.00..1.09 rows=3 width=36)
(actual time=0.010..0.014 rows=3 loops=1)
Filter: (salary > 8000)
Rows Removed by Filter: 3
-> Hash (cost=1.03..1.03 rows=3 width=36)
-> Seq Scan on department d ...
Planning Time: 0.20 ms
Execution Time: 0.06 ms
Sample Oracle output with ALLSTATS LAST:
---------------------------------------------------------------------------------
| Id | Operation | Name | Starts | E-Rows | A-Rows | Buffers |
---------------------------------------------------------------------------------
| 0 | SELECT STATEMENT | | 1 | | 3 | 14 |
|* 1 | HASH JOIN | | 1 | 3 | 3 | 14 |
| 2 | TABLE ACCESS FULL | DEPARTMENT | 1 | 3 | 3 | 7 |
|* 3 | TABLE ACCESS FULL | EMPLOYEE | 1 | 3 | 3 | 7 |
---------------------------------------------------------------------------------
Predicate Information (identified by operation id):
1 - access("D"."ID"="E"."DEPT_ID")
3 - filter("E"."SALARY">8000)
The column translation table — the core of plan reading in both engines:
| What you want to know | PostgreSQL | Oracle |
|---|---|---|
| Planner's row estimate | rows= inside cost=… |
E-Rows |
| Actual row count | actual rows= |
A-Rows |
| How many times the node ran | loops= |
Starts |
| I/O performed | Buffers: shared hit / read |
Buffers and Reads |
| Wasted work | Rows Removed by Filter |
Predicate section + A-Rows delta |
The gap between estimate and reality (rows vs actual rows, or E-Rows vs A-Rows) is the first thing to look at. If the planner guessed 10 rows and 100,000 arrived, every later decision — nested loop choice, join order, index choice — was built on a lie. And in a Nested Loop, remember to multiply the inner node's cost by loops/Starts.
And to find which query is guilty in the first place:
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT queryid, calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
FETCH FIRST 10 ROWS ONLY;-- Oracle's native equivalent: the V$SQL view (and AWR / ASH for history)
SELECT sql_id, executions,
ROUND(elapsed_time/1e6, 1) AS total_sec,
ROUND(elapsed_time/GREATEST(executions,1)/1e3, 2) AS mean_ms,
buffer_gets, rows_processed, sql_text
FROM v$sql
ORDER BY elapsed_time DESC
FETCH FIRST 10 ROWS ONLY;The three classic interview problems, in both engines
1. Second-highest salary
-- Window approach (handles ties via DENSE_RANK)
WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employee
)
SELECT DISTINCT salary FROM ranked WHERE r = 2;
-- Classic subquery: always exactly one row, graceful when there is no second value
SELECT MAX(salary) AS second_highest FROM employee
WHERE salary < (SELECT MAX(salary) FROM employee);
-- The row-limiting form (which has different NULL behavior!)
SELECT DISTINCT salary FROM employee
ORDER BY salary DESC OFFSET 1 ROWS FETCH FIRST 1 ROW ONLY;WITH ranked AS (
SELECT salary, DENSE_RANK() OVER (ORDER BY salary DESC) AS r FROM employee
)
SELECT DISTINCT salary FROM ranked WHERE r = 2;
SELECT MAX(salary) AS second_highest FROM employee
WHERE salary < (SELECT MAX(salary) FROM employee);
SELECT DISTINCT salary FROM employee
ORDER BY salary DESC OFFSET 1 ROWS FETCH FIRST 1 ROW ONLY;The subquery form has a subtlety that matters: if there is no second salary it gracefully returns NULL, because an aggregate over an empty set always produces one row. By contrast the OFFSET 1 FETCH FIRST 1 form returns no row at all, which turns into an exception in your client code. That is precisely the kind of detail that earns points in an interview.
2. Top-N per group ("greatest-N-per-group")
-- The canonical, portable pattern
SELECT dept_id, name, salary FROM (
SELECT e.*,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) AS rn
FROM employee e
) t WHERE rn <= 2; -- top 2 per department
-- PostgreSQL's shortcut for top-1
SELECT DISTINCT ON (dept_id) dept_id, name, salary
FROM employee ORDER BY dept_id, salary DESC, id;SELECT dept_id, name, salary FROM (
SELECT e.*,
ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) AS rn
FROM employee e
) t WHERE rn <= 2;
-- Oracle's shortcut for top-1 (one pass, no subquery)
SELECT dept_id,
MAX(name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC, id) AS name,
MAX(salary) KEEP (DENSE_RANK FIRST ORDER BY salary DESC, id) AS salary
FROM employee GROUP BY dept_id;ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) is non-deterministic when two people earn the same. Adding , id makes it deterministic. Say this out loud in an interview even if you are not asked — it is an unmistakable sign of production experience. If all tied employees must qualify, swap in RANK/DENSE_RANK.
3. Gaps and islands
The magic trick: value - ROW_NUMBER() is constant within a consecutive run, because both the value and the row number increase by one together; the moment a gap appears the value jumps and the number does not.
CREATE TABLE logins (user_id INT, day INT);
INSERT INTO logins VALUES (1,1),(1,2),(1,3),(1,6),(1,7),(1,10);
WITH grp AS (
SELECT day, day - ROW_NUMBER() OVER (ORDER BY day) AS island_key
FROM logins WHERE user_id = 1
)
SELECT MIN(day) AS start_day, MAX(day) AS end_day, COUNT(*) AS len
FROM grp GROUP BY island_key ORDER BY start_day;
-- islands: [1-3], [6-7], [10-10]; the gaps are 4-5 and 8-9CREATE TABLE logins (user_id NUMBER(10), day NUMBER(10));
INSERT ALL
INTO logins VALUES (1,1) INTO logins VALUES (1,2) INTO logins VALUES (1,3)
INTO logins VALUES (1,6) INTO logins VALUES (1,7) INTO logins VALUES (1,10)
SELECT * FROM dual;
COMMIT;
WITH grp AS (
SELECT day, day - ROW_NUMBER() OVER (ORDER BY day) AS island_key
FROM logins WHERE user_id = 1
)
SELECT MIN(day) AS start_day, MAX(day) AS end_day, COUNT(*) AS len
FROM grp GROUP BY island_key ORDER BY start_day;Imagine the numbers of the occupied seats. If people sit back-to-back (1,2,3), "seat number minus sequential counter" is the same for all of them — as if they share a group label. As soon as an empty seat falls in the middle, the label changes and a new group begins.
Oracle has one extra weapon nobody else has: MATCH_RECOGNIZE (since 12c) — pattern matching over ordered rows:
-- PostgreSQL has no MATCH_RECOGNIZE; the island_key trick is the portable answer
WITH grp AS (
SELECT user_id, day,
day - ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day) AS island_key
FROM logins
)
SELECT user_id, MIN(day) AS start_day, MAX(day) AS end_day, COUNT(*) AS len
FROM grp GROUP BY user_id, island_key ORDER BY user_id, start_day;-- MATCH_RECOGNIZE: describe the pattern directly, no arithmetic trick needed
SELECT * FROM logins
MATCH_RECOGNIZE (
PARTITION BY user_id
ORDER BY day
MEASURES FIRST(day) AS start_day, LAST(day) AS end_day, COUNT(*) AS len
ONE ROW PER MATCH
PATTERN (a b*)
DEFINE b AS b.day = PREV(b.day) + 1
);If you work on Oracle and your problem is "a sequence of events" (three consecutive failed logins, a V-shape in prices, sessionizing with a 30-minute gap), MATCH_RECOGNIZE turns a hundred lines of SQL into ten. In PostgreSQL you must build all of it from window functions and island keys. Simply knowing the tool exists is itself a senior-level differentiator.
PostgreSQL ↔ Oracle quick reference
Keep this table separate; it is exactly what you need in an interview or in the middle of a migration:
| Topic | PostgreSQL 16/17 | Oracle 19c / 23ai |
|---|---|---|
| Text | TEXT / VARCHAR(n) |
VARCHAR2(n) · CLOB |
| Exact numeric | NUMERIC(p,s) |
NUMBER(p,s) |
| Date | DATE (date only) · timestamptz |
DATE (date and time) · TIMESTAMP WITH TIME ZONE |
| Current time | now() (transaction start) · clock_timestamp() |
SYSDATE · SYSTIMESTAMP |
| Empty string | a real value, ≠ NULL | equals NULL |
| Dummy table | not needed | DUAL (optional from 23ai) |
| Pagination | LIMIT n OFFSET m · FETCH FIRST |
OFFSET m ROWS FETCH FIRST n ROWS ONLY (12c+) · ROWNUM |
| Upsert | INSERT … ON CONFLICT · MERGE (15+) |
MERGE (9i+) |
| Update from another table | UPDATE … FROM |
MERGE or a correlated subquery |
| Return the inserted row | RETURNING (result set) |
RETURNING … INTO (bind only) |
| String aggregation | STRING_AGG(x, s ORDER BY …) |
LISTAGG(x, s) WITHIN GROUP (ORDER BY …) |
| Conditional aggregation | agg(...) FILTER (WHERE …) |
agg(CASE WHEN … THEN … END) |
| Set difference | EXCEPT / EXCEPT ALL |
MINUS (and EXCEPT from 21c) |
| Recursive CTE | WITH RECURSIVE, column list optional |
WITH without RECURSIVE, column list required |
| Hierarchy traversal | recursive CTE only | recursive CTE and CONNECT BY |
| NULL-safe comparison | IS [NOT] DISTINCT FROM |
DECODE(a,b,0,1)=0 · LNNVL |
IGNORE NULLS in windows |
not supported (through 17) | supported |
| First row per group | DISTINCT ON |
KEEP (DENSE_RANK FIRST ORDER BY …) |
| Row pattern matching | not supported | MATCH_RECOGNIZE (12c+) |
| Partial index | CREATE INDEX … WHERE … |
function-based index with CASE … END |
| Index payload columns | INCLUDE (...) |
append the column to the key |
| NULLs in a B-tree | stored | an all-NULL key is not stored |
| Temporarily disable an index | none (DROP inside a transaction) |
ALTER INDEX … INVISIBLE |
| Refresh statistics | ANALYZE · CREATE STATISTICS |
DBMS_STATS.GATHER_TABLE_STATS |
| Plan with real numbers | EXPLAIN (ANALYZE, BUFFERS) |
/*+ GATHER_PLAN_STATISTICS */ + DISPLAY_CURSOR('ALLSTATS LAST') |
| Expensive queries | pg_stat_statements |
V$SQL · AWR · ASH |
| Optimizer hints | none (pg_hint_plan extension) |
full /*+ … */ system · DBMS_SPM |
| Transactional DDL | yes | no (implicit commit) |
Common pitfalls & gotchas
- NULL is not a value; it is "unknown."
WHERE x = NULLnever matches — useIS NULL. NOT INwith NULLs returns no rows — in both engines. Always preferNOT EXISTS.COUNT(column)skips NULLs,COUNT(*)does not.AVGignores NULLs too.- In Oracle,
''is NULL. Any logic relying on "empty but not null" breaks there. - In Oracle,
DATEcarries a time.col = DATE '2023-05-05'misses timestamped rows; use a half-open range. ROWNUMis assigned beforeORDER BY, andWHERE ROWNUM > 1never returns a row.RANGE, notROWS, is the default frame — in both engines; spell outROWSfor running totals.- Implicit type coercion kills indexes: comparing a numeric column to a string, or wrapping the column in a function.
SELECT *in production breaks covering-index plans and is fragile to schema changes.- Money in floating point: use
NUMERIC/NUMBER, neverFLOAT/BINARY_DOUBLE. - In Oracle, DDL commits your transaction. A
CREATE TABLEmid-script finalizes every pending change before it. ROW_NUMBERwithout a tiebreaker is non-deterministic, and in pagination it makes a row appear on two pages or none.
Best practices
- Filter early (
WHEREbeforeHAVING), select only the columns you need, and index for your actual query shapes — verify withEXPLAIN ANALYZEorDISPLAY_CURSOR, do not guess. - Design composite indexes with equality columns first, range/sort column last.
- Prefer
EXISTS/NOT EXISTSoverIN/NOT INfor correlated existence checks and NULL safety. - Keep statistics fresh:
ANALYZEin PostgreSQL,DBMS_STATSin Oracle — especially after bulk loads. - Reach for window functions before self-joins for ranking, running totals, and inter-row comparison.
- In new code prefer the shared standard syntax:
JOIN … ON(not(+)),FETCH FIRST(notLIMITorROWNUM),CASE/COALESCE(notDECODE/NVL), recursive CTEs (notCONNECT BY). - Wherever you use an engine-specific feature (
ON CONFLICT,DISTINCT ON,KEEP DENSE_RANK,MATCH_RECOGNIZE), leave a comment naming the other engine's equivalent.
Interview Questions
Try to answer first, then read the answer.
WHERE filters individual rows before grouping and cannot use aggregates. HAVING filters groups after GROUP BY and can. Push predicates into WHERE where possible to shrink the input to aggregation. Identical in both engines; only the error code differs (42803 in PostgreSQL, ORA-00979/ORA-00934 in Oracle).
For 9000, 9000, 8000: ROW_NUMBER → 1,2,3 (always unique); RANK → 1,1,3 (ties share, then a gap); DENSE_RANK → 1,1,2 (no gap). All three behave identically in PostgreSQL and Oracle — Oracle has had them since 8i and calls them analytic functions. Use ROW_NUMBER plus a tiebreaker for exactly-N, and DENSE_RANK when all ties should qualify.
ROWNUM is an Oracle pseudo-column assigned during filtering, i.e. before ORDER BY; so WHERE ROWNUM <= 3 ORDER BY salary DESC returns three arbitrary rows, not the top three, and must be used inside a sorted inline view. Also WHERE ROWNUM > 1 always returns zero rows, because the counter only advances for rows that pass the filter. ROW_NUMBER() is a standard window function evaluated after the window's ordering, behaves identically in both engines, and is the only way to number rows per group. FETCH FIRST n ROWS ONLY is the ANSI clause (Oracle 12c+, PostgreSQL 8.4+) and is the correct, portable form today. PostgreSQL has no ROWNUM equivalent.
A condition on the right table's column in WHERE eliminates the NULL-padded outer rows (since NULL = x is UNKNOWN), silently degrading the LEFT JOIN to an INNER JOIN. Fix: move the condition into ON. In Oracle's legacy syntax the same thing happens when you forget (+) on one of that table's predicates — another reason to write ANSI syntax.
In Oracle, '' for VARCHAR2 is NULL: '' IS NULL is true, LENGTH('') is NULL, and WHERE name = '' always returns zero rows. In PostgreSQL, '' is a real zero-length string entirely distinct from NULL. Consequences: in Oracle you cannot fill a NOT NULL column with '' (ORA-01400), and application logic that distinguishes "empty" from "undefined" breaks on migration. Rule: never rely on the difference between '' and NULL.
PostgreSQL's DATE is year/month/day only; Oracle's DATE always carries hours, minutes and seconds. The classic bug: WHERE hired_on = DATE '2023-05-05' matches only rows at exactly 00:00:00. The wrong fix is TRUNC(hired_on) = …, which kills the index; the right fix is the half-open range >= DATE '2023-05-05' AND < DATE '2023-05-06'. If you truly need TRUNC, build a function-based index. For sub-second precision you need TIMESTAMP.
ON CONFLICT is PostgreSQL-specific, keys off a unique constraint, and behaves safely under concurrency. MERGE is ANSI standard (Oracle 9i+, PostgreSQL 15+), keys off a join condition, can do UPDATE/INSERT/DELETE in one statement, and its source may be a full query — so it is stronger for batch loads, but it is not inherently atomic against concurrent writers and can hit a unique-violation. In Oracle, if the source matches a target row more than once you get ORA-30926.
When the predicate is non-sargable (function on the column, leading wildcard, type mismatch), when it selects a large fraction of the table, when statistics are stale, or when the index column order does not match the query. There is one Oracle-specific case too: because Oracle's B-tree does not store all-NULL keys, WHERE col IS NULL never uses a single-column index — the fix is to make the index composite. PostgreSQL has no such limitation.
An index on (a, b, c) is sorted by a, then b, then c. It serves predicates on a, a,b and a,b,c, but not b alone or c alone, because those are only ordered within the preceding column's groups. Put equality predicates first and the range/sort column last. Oracle footnote: without the prefix, Oracle may choose an INDEX FAST FULL SCAN, which beats a full table scan but is still far worse than a real range scan.
A covering index contains all the columns the query needs, so it can answer without touching the table. In PostgreSQL since 11 you can attach non-key payload with INCLUDE; but because MVCC keeps old versions inside the table, the engine must consult the visibility map and falls back to heap fetches when a page is not all-visible (Heap Fetches > 0) — the cure is VACUUM. Oracle has no INCLUDE (append the column to the key instead) but has no such problem thanks to undo-based MVCC; the sign of success is the absence of a TABLE ACCESS BY INDEX ROWID step in the plan.
CONNECT BY is Oracle's proprietary syntax: START WITH gives the anchor, CONNECT BY PRIOR the step, and you get LEVEL, CONNECT_BY_ROOT, SYS_CONNECT_BY_PATH and CONNECT_BY_ISLEAF for free, plus ORDER SIBLINGS BY. The recursive CTE is ANSI standard and is PostgreSQL's only option. Three syntax differences to know: PostgreSQL requires the RECURSIVE keyword and Oracle does not have it; Oracle requires the CTE column alias list (ORA-32039 otherwise); the CYCLE clause produces a boolean in PostgreSQL but two text values in Oracle (TO 'Y' DEFAULT 'N').
An Oracle-specific aggregate meaning "within each group, take this column's value from the row where the ordering column is first/last" — e.g. MAX(name) KEEP (DENSE_RANK FIRST ORDER BY salary DESC) gives each department's top earner in a single pass with no subquery. PostgreSQL has no direct equivalent; three substitutes: DISTINCT ON (dept_id) … ORDER BY dept_id, salary DESC when you want the whole row, (ARRAY_AGG(name ORDER BY salary DESC))[1] for a single column, or the standard ROW_NUMBER() + rn = 1 pattern.
If any x is NULL, the expression evaluates to UNKNOWN for every row, so nothing qualifies — identically in PostgreSQL and Oracle. Fix with NOT EXISTS, or add WHERE x IS NOT NULL inside the subquery. Oracle has a third tool: LNNVL(col = x), which returns true when the condition is false or UNKNOWN; the PostgreSQL equivalent is col IS DISTINCT FROM x.
ROWS counts physical rows; RANGE groups peers with equal ORDER BY values into one logical boundary. If you write ORDER BY without an explicit frame, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — in both engines. Result: with duplicate order keys, all those rows get the same running total instead of incrementing per row. For a true running total always spell out ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
In PostgreSQL it is version-dependent: before 12 a CTE was always materialized (an optimization fence blocking predicate push-down); since 12, single-reference side-effect-free CTEs are inlined by default and you can force either behavior with AS MATERIALIZED / AS NOT MATERIALIZED — code that relied on the old fence regresses on 12+. Oracle has no such version rule: the CBO decides on cost and you steer it with /*+ MATERIALIZE */ and /*+ INLINE */; when it materializes you see TEMP TABLE TRANSFORMATION and LOAD AS SELECT in the plan. The sentence that scores points: "in PostgreSQL this is a syntactic rule; in Oracle it is a cost-based decision."
PostgreSQL's EXPLAIN shows estimates only; EXPLAIN ANALYZE actually runs the query and reports actual rows, loops and (with BUFFERS) real I/O — and because it runs, you must wrap INSERT/UPDATE/DELETE in BEGIN … ROLLBACK. In Oracle this is two tools: EXPLAIN PLAN FOR + DBMS_XPLAN.DISPLAY estimates without executing (so it misses bind peeking and adaptive plans and can mislead you); the true equivalent is to run the statement with /*+ GATHER_PLAN_STATISTICS */ and then read DBMS_XPLAN.DISPLAY_CURSOR(format => 'ALLSTATS LAST'), which gives E-Rows, A-Rows, Starts and Buffers. SET AUTOTRACE ON is the quick, lower-detail route. For finding the guilty query: pg_stat_statements vs V$SQL/AWR.
SELECT MAX(salary) FROM employee WHERE salary < (SELECT MAX(salary) FROM employee); returns NULL (one row) cleanly when there is no second value, because an aggregate over an empty set still yields a row. The ORDER BY salary DESC OFFSET 1 FETCH FIRST 1 ROW ONLY approach returns no row in that case — a difference that surfaces as an exception in the client. The DENSE_RANK() = 2 window approach handles ties by returning all second-place earners. All three are written identically in PostgreSQL and Oracle if you use FETCH FIRST instead of LIMIT.
For a monotonically increasing series, value - ROW_NUMBER() OVER (ORDER BY value) is constant across a consecutive run and changes at each gap; group by that difference and use MIN/MAX for each island's bounds. This works identically in both engines. Oracle also has a dedicated tool: MATCH_RECOGNIZE (12c+), which describes the pattern directly — PATTERN (a b*) with DEFINE b AS b.day = PREV(b.day) + 1 — and is far more readable for complex sequences (three consecutive failures, V-shapes, sessionizing). PostgreSQL has nothing equivalent.
SELECT COUNT(*), COUNT(dept_id), COUNT(DISTINCT dept_id) FROM employee;SELECT COUNT(*), COUNT(dept_id), COUNT(DISTINCT dept_id) FROM employee;With the seed data (6 rows, Uma's dept_id is NULL, departments 1,1,1,2,2): COUNT(*)=6, COUNT(dept_id)=5 (skips the NULL), COUNT(DISTINCT dept_id)=2. The result is identical in both engines.
SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) rn
FROM employee e
) t WHERE rn <= 2;SELECT * FROM (
SELECT e.*, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC, id) rn
FROM employee e
) t WHERE rn <= 2;The query is word-for-word identical in both engines — a nice thing to point out in an interview. PARTITION BY dept_id scopes the ranking per department and , id makes it deterministic. If all tied employees must be included, use RANK/DENSE_RANK. For the top-1 case, PostgreSQL offers the DISTINCT ON (dept_id) shortcut and Oracle offers KEEP (DENSE_RANK FIRST ORDER BY salary DESC), both usually faster than the form above.
SQL has two worlds you must always hold at once: the logical (what the result means) and the physical (what the database does). The real execution order is FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → pagination. Joins are INNER and OUTER; watch the ON-vs-WHERE trap, write anti-joins with NOT EXISTS, and treat Oracle's (+) as read-only legacy. GROUP BY collapses rows; STRING_AGG is LISTAGG, and ORA-01489 overflow is a genuine production trap. CTEs give readability and recursion — materialization changed in PostgreSQL 12 and is cost-based in Oracle; and CONNECT BY is the Oracle heritage you must be able to translate into a recursive CTE. Window functions (Oracle's "analytic functions") give ranks, running totals and inter-row comparison without collapsing rows; know the ranking trio, the ROWS/RANGE difference, Oracle-only IGNORE NULLS, and KEEP DENSE_RANK. For pagination, memorize the ROWNUM / ROW_NUMBER() / FETCH FIRST trio and why ROWNUM > 1 never works, and use keyset pagination for deep pages. On NULLs, the NOT IN trap is shared, but "empty string equals NULL" and "DATE carries a time" belong to Oracle alone. Indexes turn O(n) into O(log n), follow the leftmost-prefix rule, are not free, and in Oracle do not store all-NULL keys. Finally, replace guessing with EXPLAIN (ANALYZE, BUFFERS) in PostgreSQL and /*+ GATHER_PLAN_STATISTICS */ plus DBMS_XPLAN.DISPLAY_CURSOR('ALLSTATS LAST') in Oracle, and read the estimate/actual gap. Write the three golden problems — second-highest salary, top-N per group, and gaps-and-islands — in both dialects with your eyes closed. Now you are ready.