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;
چرا DUAL وجود دارد؟

یک جدول واقعیِ متعلق به 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.

alias در GROUP BY — جایی که دو موتور فرق می‌کنند

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 — فقط نگاه می‌کند و گزارش می‌دهد.
بزرگ‌ترین تفاوت رفتاری DDL بین دو موتور

در 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
);
نگاشت نوع‌ها — اولین سؤال هر مهاجرت

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');  -- هنوز دپارتمانی ندارد
Oracle خودکار commit نمی‌کند

در 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';
تلهٔ خطرناکِ UPDATE … FROM

اگر 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);
ON CONFLICT در برابر MERGE

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';
هرگز TRUNC روی ستون ایندکس‌شده نزن

راهِ وسوسه‌کننده این است که بنویسی 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 در برابر now()

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;
NVL در برابر COALESCE — تفاوتی که سنیورها می‌پرسند

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;
پیش از 12c، Oracle اصلاً IDENTITY نداشت

الگوی کلاسیک: یک 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;

نحو قدیمیِ (+) در 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;
چرا (+) را در کد جدید ننویس

سه دلیل: (۱) (+) نمی‌تواند 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';

چرا؟ 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;
تلهٔ مشترک هر دو موتور: NOT IN با 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 اضافه می‌کند)
قضاوت سنیور دربارهٔ hint

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;            -- سپس گروه‌ها

نکات ظریفی که در مصاحبه دقیقاً همین‌ها را می‌پرسند: 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;
LISTAGG و خطای ORA-01489

خروجی 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;
چرا `COUNT(CASE … THEN 1 END)` و نه `THEN 1 ELSE 0 END`؟

چون 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;   -- حقوق بالاتر از میانگین دپارتمان خودش

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;
دربارهٔ MATERIALIZE و INLINE

این دو 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 -> CEO
دو تفاوت که همان بار اول زمینت می‌زند

(۱) در 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;
ترجمهٔ ذهنی CONNECT BY به CTE

START WITH ⇔ عضو لنگر · CONNECT BY PRIOR parent = child ⇔ عضو بازگشتی با شرط join · LEVEL ⇔ ستون depth که خودت با +1 می‌سازی · SYS_CONNECT_BY_PATH ⇔ الحاق رشته‌ای مسیر · CONNECT_BY_ROOT ⇔ ستونی که از لنگر بدون تغییر حمل می‌شود · NOCYCLE ⇔ بند CYCLE · ORDER SIBLINGS BYSEARCH DEPTH FIRST BY … SET ord و بعد ORDER BY ord.

(تفاوت دو موتور) CONNECT BY در برابر CTE بازگشتی — کدام و چرا؟

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;

سه‌گانهٔ رتبه‌بندی یک تلهٔ محبوب مصاحبه است. با حقوق‌های ۹۰۰۰، ۹۰۰۰، ۸۰۰۰ در یک 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 است.
IGNORE NULLS — تفاوتی که در گزارش‌های مالی دردسر می‌سازد

الگوی «آخرین مقدار شناخته‌شده را حمل کن» (مثلاً آخرین قیمت ثبت‌شده تا این تاریخ) در 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;
ستون سوم را نگاه کن

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;
چرا KEEP DENSE_RANK را باید بشناسی

اول اینکه در کد Oracle واقعی زیاد می‌بینی‌اش و بدون آن خواندن گزارش‌های مالی غیرممکن می‌شود. دوم اینکه جواب درجه‌یکِ سؤالِ «top-1 در هر گروه را بدون window function بنویس» است. سوم اینکه معمولاً از الگویِ «subquery با ROW_NUMBER و فیلتر rn = 1» سریع‌تر است چون فقط یک عبور روی داده لازم دارد. MAX(name) بیرونی صرفاً برای رفع ابهام وقتی چند ردیف هم‌رتبه باشند لازم است.

چرا window function به‌جای self-join؟

هر جا به رتبه‌بندی، جمع دونده، یا مقایسهٔ ردیف با ردیف نیاز داری، اول سراغ 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 19c هستی

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;

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;
`WHERE ROWNUM > 1` هرگز هیچ ردیفی برنمی‌گرداند

ROWNUM فقط وقتی به یک ردیف تخصیص می‌یابد که آن ردیف از فیلتر عبور کند. اولین کاندید ROWNUM = 1 می‌گیرد؛ شرط ROWNUM > 1 رد می‌شود، پس آن ردیف دور ریخته می‌شود و شمارنده جلو نمی‌رود. ردیف بعدی هم دوباره کاندیدِ ROWNUM = 1 است… و این تا آخر جدول ادامه دارد. نتیجه: صفر ردیف، برای همیشه. به همین دلیل صفحه‌بندی قدیمی Oracle همیشه به دو لایهٔ تودرتو نیاز داشت.

چرا OFFSET بزرگ در هر دو موتور سم است

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 نه‌تنها طولانی‌تر است، بلکه بهینه‌ساز هم سخت‌تر می‌تواند آن را به یک 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 نمی‌توانی ستون 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;
جایی که دو موتور غافلگیرکننده موافق‌اند

هر دو NULL را «بزرگ‌ترین مقدار» می‌شمارند؛ یعنی پیش‌فرضِ ORDER BY col ASC برابر NULLS LAST و پیش‌فرضِ DESC برابر NULLS FIRST است (برخلاف MySQL و SQL Server). با این حال در کد تولیدی همیشه صریح بنویس — هم برای خوانایی، هم چون ایندکس فقط وقتی می‌تواند مرتب‌سازی را رایگان تأمین کند که ترتیبِ NULL آن با پرس‌وجو بخوانَد.

LNNVL — ابزار کمترشناخته‌شدهٔ Oracle

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;                    -- نمی‌تواند

قاعدهٔ عملی: ستون‌های تساوی اول، ستون بازه/مرتب‌سازی آخر.

دفترچهٔ دوکلیدی

دفترچه‌ای که اول بر اساس شهر و بعد درون هر شهر بر اساس نام‌خانوادگی مرتب است. «تهران / محمدی» آسان است. همهٔ «محمدی»ها در کل کشور؟ سخت، چون در هر شهر جدا مرتب شده‌اند و پخش‌اند. ستونِ چپ کلیدِ ورود است.

یک تفاوت جدی: NULL در ایندکس

در 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» بگرد
تفاوت بزرگ MVCC: چرا index-only scan در PostgreSQL همیشه رایگان نیست

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;
«partial index» در Oracle معنای دیگری دارد

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);
دو راه برای «آیا این ایندکس واقعاً لازم است؟»

در 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';
آمار کهنه، علتِ شمارهٔ یکِ پلن‌های بد

وقتی برنامه‌ریز فکر می‌کند ۱۰ ردیف برمی‌گردد ولی واقعاً ۱۰ میلیون برمی‌گردد، تصمیمِ 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 دروغ می‌گوید، DISPLAY_CURSOR نه

EXPLAIN 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;

سه مسئلهٔ کلاسیک مصاحبه، در هر دو موتور

۱. دومین حقوق بیشینه

-- رویکرد 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;

فرم زیرپرس‌وجو ظرافت مهمی دارد: اگر حقوق دومی نباشد، به‌آرامی 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;
همیشه tiebreaker بگذار

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 هستند
صندلی‌های ردیف سینما

شماره‌ی صندلی‌های اشغال‌شده را داری. اگر افراد پشت‌سرهم نشسته باشند (۱،۲،۳)، «شماره‌ی صندلی منهای شمارنده‌ی ترتیبی» برای همه‌شان یکی است — انگار یک برچسبِ گروهی دارند. تا وقتی یک صندلی خالی وسط بیفتد، برچسب عوض می‌شود و گروه جدید شروع می‌شود.

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

اگر روی 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/ROWNUMCASE/COALESCE (نه DECODE/NVL)، CTE بازگشتی (نه CONNECT BY).
  • هرجا از ویژگی اختصاصی استفاده کردی (ON CONFLICT، DISTINCT ON، KEEP DENSE_RANK، MATCH_RECOGNIZE)، یک کامنت بگذار که معادل موتور دیگر چیست.

سؤالات مصاحبه

اول خودت جواب بده، بعد پاسخ را بخوان.

۱. تفاوت WHERE و HAVING چیست؟

WHERE ردیف‌های منفرد را پیش از گروه‌بندی فیلتر می‌کند و نمی‌تواند از aggregate استفاده کند. HAVING گروه‌ها را پس از GROUP BY فیلتر می‌کند و می‌تواند. تا حد امکان شرط‌ها را در WHERE بگذار تا ورودیِ تجمیع کوچک شود. در هر دو موتور یکسان است؛ فقط کد خطا فرق دارد (42803 در PostgreSQL، ORA-00979/ORA-00934 در Oracle).

۲. RANK در برابر DENSE_RANK در برابر ROW_NUMBER در تساوی

برای ۹۰۰۰، ۹۰۰۰، ۸۰۰۰: ROW_NUMBER → ۱،۲،۳ (همیشه یکتا)؛ RANK → ۱،۱،۳ (رتبهٔ مشترک، سپس شکاف)؛ DENSE_RANK → ۱،۱،۲ (بدون شکاف). هر سه در PostgreSQL و Oracle یکسان‌اند — Oracle این‌ها را از 8i دارد و analytic function می‌نامدشان. برای دقیقاً-N از ROW_NUMBER با tiebreaker، و برای «همهٔ هم‌رتبه‌ها» از DENSE_RANK.

۳. (تفاوت دو موتور) ROWNUM در برابر ROW_NUMBER() در برابر FETCH FIRST

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 ندارد.

۴. (تله) چرا LEFT JOIN با فیلتر روی جدول راست در WHERE ردیف‌های کمتری برمی‌گرداند؟

شرطی روی ستون جدول راست در WHERE، ردیف‌های بیرونیِ پرشده با NULL را حذف می‌کند (چون NULL = x برابر UNKNOWN است) و بی‌صدا LEFT JOIN را به INNER تنزل می‌دهد. راه‌حل: شرط را به ON منتقل کن. در نحو قدیمی Oracle همین وقتی رخ می‌دهد که یک (+) را روی یکی از شرط‌های همان جدول جا بیندازی — دلیل دیگری برای نوشتن نحو ANSI.

۵. (تفاوت دو موتور) رشتهٔ خالی در Oracle و PostgreSQL

در Oracle، '' برای VARCHAR2 دقیقاً همان NULL است: '' IS NULL برابر true، LENGTH('') برابر NULL، و WHERE name = '' همیشه صفر ردیف. در PostgreSQL، '' رشتهٔ واقعیِ طول‌صفر است و کاملاً از NULL جداست. پیامدها: در Oracle نمی‌توانی ستون NOT NULL را با '' پر کنی (ORA-01400)، و منطقی که «خالی» و «تعریف‌نشده» را جدا می‌کند در مهاجرت می‌شکند. قاعده: هرگز به تفاوت '' و NULL تکیه نکن.

۶. (تفاوت دو موتور) DATE در Oracle چه فرقی دارد و چه باگی می‌سازد؟

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 لازم است.

۷. (تفاوت دو موتور) `MERGE` را با `INSERT … ON CONFLICT` مقایسه کن

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 در پلن نباشد.

۱۱. (سخت · تفاوت دو موتور) CTE بازگشتی در برابر CONNECT BY

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').

۱۲. (تفاوت دو موتور) `KEEP (DENSE_RANK FIRST …)` چیست و معادلش در PostgreSQL؟

یک تجمیعِ مخصوص 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.

۱۳. (تله) چرا `col NOT IN (SELECT x FROM s)` گاهی صفر ردیف برمی‌گرداند؟

اگر حتی یک 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 در قاب window

ROWS ردیف‌های فیزیکی را می‌شمارد؛ RANGE هم‌ترازها با مقادیر ORDER BY برابر را در یک مرز منطقی جمع می‌کند. اگر ORDER BY بنویسی و قاب را صریح نکنی، پیش‌فرض RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW است — در هر دو موتور. نتیجه: با کلیدهای مرتب‌سازی تکراری، همهٔ آن ردیف‌ها یک جمعِ دونده‌ی یکسان می‌گیرند. برای جمع دونده‌ی واقعی همیشه ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW را صریح بنویس.

۱۵. (سخت · تفاوت دو موتور) آیا CTE کارایی را تغییر می‌دهد؟

در 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 ANALYZE را با EXPLAIN PLAN و AUTOTRACE مقایسه کن

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.

۱۷. «دومین حقوق بیشینه» را حل کن و رفتار NULL را توجیه کن

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 استفاده کنی.

۱۸. (سخت) gaps-and-islands و ابزار اضافهٔ Oracle

برای یک سری صعودیِ یکنوا، 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;

با دادهٔ بذر (۶ ردیف، 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;

پرس‌وجو در هر دو موتور کلمه‌به‌کلمه یکسان است — نکتهٔ خوبی برای گفتن در مصاحبه. 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.

Roadmap for this chapter

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 ANALYZE in PostgreSQL, with DBMS_STATS in 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 SELECT must have a FROM.
A mental model

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;
Why DUAL exists at all

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.

Common pitfall

"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.

Aliases in GROUP BY — where the engines diverge

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.
The single biggest DDL behavior difference

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
);
Type mapping — the first question in any migration

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 yet
Oracle does not autocommit

psql 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';
The dangerous trap in UPDATE … FROM

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);
ON CONFLICT vs MERGE

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';
Never wrap an indexed column in TRUNC

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 vs now()

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;
NVL vs COALESCE — the detail seniors ask about

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;
Before 12c, Oracle had no IDENTITY at all

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
A party and the guest list

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'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;
Why not to write (+) in new code

Three 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';

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.

Know this in interviews

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;
A trap shared by both engines: NOT IN with NULLs

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
Three ways to find a name

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)
Senior judgment about hints

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 groups

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 golden performance rule

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;
LISTAGG and ORA-01489

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;
Why `COUNT(CASE … THEN 1 END)` and not `THEN 1 ELSE 0 END`?

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

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;
About MATERIALIZE and INLINE

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 -> CEO
Two differences that trip you on the very first try

(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.

Climbing the org ladder

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;
Translating CONNECT BY to a CTE in your head

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 BYSEARCH DEPTH FIRST BY … SET ord then ORDER BY ord.

(Engine difference) CONNECT BY vs recursive CTE — which and why?

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.

A window that moves with you

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;

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
A footrace and the podium

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.
IGNORE NULLS — the difference that hurts in financial reporting

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;
Look at the third column

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;
Why you must know KEEP DENSE_RANK

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.

Why window functions over self-joins?

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;
If you are on Oracle 19c

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;

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;
`WHERE ROWNUM > 1` never returns any rows

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.

Why a large OFFSET is poison in both engines

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;
Why this difference matters

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
Why this trap is genuinely dangerous

Three 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;
Where the two engines surprisingly agree

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 — Oracle's lesser-known tool

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%'.

An alphabetical phone book

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 served

The practical rule: equality columns first, the range/sort column last.

A two-key directory

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.

A serious difference: NULLs in indexes

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
The big MVCC difference: index-only scans are not always free in PostgreSQL

PostgreSQL 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;
"Partial index" means something else in Oracle

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);
Two ways to answer "do we actually need this index?"

In 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.)

An index is not free

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';
Stale statistics are the #1 cause of bad plans

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;
EXPLAIN PLAN can lie; DISPLAY_CURSOR cannot

EXPLAIN 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 biggest signal is the same in both engines

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;

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;

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;
Always add a tiebreaker

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-9
Seats in a cinema row

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;
The value of MATCH_RECOGNIZE

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 = NULL never matches — use IS NULL.
  • NOT IN with NULLs returns no rows — in both engines. Always prefer NOT EXISTS.
  • COUNT(column) skips NULLs, COUNT(*) does not. AVG ignores NULLs too.
  • In Oracle, '' is NULL. Any logic relying on "empty but not null" breaks there.
  • In Oracle, DATE carries a time. col = DATE '2023-05-05' misses timestamped rows; use a half-open range.
  • ROWNUM is assigned before ORDER BY, and WHERE ROWNUM > 1 never returns a row.
  • RANGE, not ROWS, is the default frame — in both engines; spell out ROWS for 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, never FLOAT/BINARY_DOUBLE.
  • In Oracle, DDL commits your transaction. A CREATE TABLE mid-script finalizes every pending change before it.
  • ROW_NUMBER without a tiebreaker is non-deterministic, and in pagination it makes a row appear on two pages or none.

Best practices

  • Filter early (WHERE before HAVING), select only the columns you need, and index for your actual query shapes — verify with EXPLAIN ANALYZE or DISPLAY_CURSOR, do not guess.
  • Design composite indexes with equality columns first, range/sort column last.
  • Prefer EXISTS/NOT EXISTS over IN/NOT IN for correlated existence checks and NULL safety.
  • Keep statistics fresh: ANALYZE in PostgreSQL, DBMS_STATS in 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 (not LIMIT or ROWNUM), CASE/COALESCE (not DECODE/NVL), recursive CTEs (not CONNECT 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.

1. What is the difference between WHERE and HAVING?

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).

2. RANK vs DENSE_RANK vs ROW_NUMBER on ties

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.

3. (Engine difference) ROWNUM vs ROW_NUMBER() vs FETCH FIRST

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.

4. (Gotcha) Why does a LEFT JOIN with a filter on the right table in WHERE return fewer rows?

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.

5. (Engine difference) The empty string in Oracle vs PostgreSQL

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.

6. (Engine difference) How does Oracle's DATE differ, and what bug does it cause?

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.

7. (Engine difference) Compare `MERGE` with `INSERT … ON CONFLICT`

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.

8. When will the planner ignore an index?

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.

9. Explain the leftmost-prefix rule

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.

10. (Hard · engine difference) Covering indexes and index-only scans

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.

11. (Hard · engine difference) Recursive CTE vs CONNECT BY

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').

12. (Engine difference) What is `KEEP (DENSE_RANK FIRST …)` and what is the PostgreSQL equivalent?

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.

13. (Gotcha) Why does `col NOT IN (SELECT x FROM s)` sometimes return zero rows?

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.

14. (Hard) ROWS vs RANGE in a window frame

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.

15. (Hard · engine difference) Does wrapping a query in a CTE change performance?

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."

16. (Engine difference) Compare EXPLAIN ANALYZE with EXPLAIN PLAN and AUTOTRACE

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.

17. Solve "second-highest salary" and defend the NULL behavior

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.

18. (Hard) Explain gaps-and-islands and Oracle's extra tool

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.

19. What does this print?
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.

20. (Hard · engine difference) Write "top-2 highest paid per department" 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;

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.

In a nutshell

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.