{"slug": "sql-design-query-reference", "title": "SQL Design & Query Reference", "summary": "A developer with experience in PostgreSQL, MySQL/MariaDB, MSSQL, and Oracle compiled a SQL design and query reference document generated by Claude, an LLM, to serve as a knowledge transfer resource for junior engineers. The reference covers core concepts across four database types, including patterns for common query problems and anti-patterns like DISTINCT misuse.", "body_md": "I have professionally worked with **four different SQL database types** `PostgreSQL`\n\n, `MySQL`\n\n& `MariaDB`\n\n(InnoDB), `MSSQL`\n\nSQL Server 2008+ and `Oracle 19c`\n\n+. Over the course of that time I have been asked:\n\nAnd being an autodidact software engineer with an electrical engineering background in computer engineering, I would reference [The Library](https://dev.to/andreimerlescu/when-ai-creates-a-closed-source-world-2oin) to complete task A or task B or task C. Many junior engineers are in this boat as well. They may have the formal education that I lacked *not being in computer science as a major in college*, but it doesn't mean that I can't use resources at my disposal to learn and meet each of these requirements and where I've done it.\n\nThis document is *Claude's* response to my request for it to act as Staff SQL Architect and write a reference document for these **four database types** as a *knowledge transfer doc* for **junior engineers** who need a *quick reference* of these core concepts.\n\nIn all fairness, it's not wrong to have this handy. Instead of it being fragmented on Stack Overflow in the form of dozens of Questions and Answers - its been gathered through the LLM's that have [crossed the line](https://dev.to/andreimerlescu/codeberg-bans-cryptocurrency-and-llm-generated-code-projects-3aj0) with acceptable community behavior. So, before those models are *taken offline* 😅🤫🤣 I wanted to utilize this resource to help give back. It's a format and structure applicable to four distinct types of SQL databases that any developer should be comfortable with. My experience with SQL databases was extensive between 2003 and 2020. After, I became heavily involved with `MongoDB`\n\nand **NoSQL** databases including *Redis* and *PostgreSQL*. Since 2020, I've been on the administration side of the database and less on the SQL query writing sided of the database. The [Apario Database](https://dev.to/andreimerlescu/how-i-processed-666k-pages-of-flattened-pdfs-into-a-full-text-search-engine-4db3) was custom written from the ground up for an [OPEX problem](https://dev.to/andreimerlescu/how-i-reduced-my-opex-by-995-using-go-3ff7).\n\nIf you find this document useful, please give it a 💜 before you bookmark it.\n\n**How to read this doc:** Sections marked **[SAME]** behave identically on all four engines — learn the concept once and move on. Sections marked **[DIFFERS]** are where people get burned porting code or scaling up. Version numbers matter a lot here; when a feature is version-gated I say so, and you should still verify against the exact version in your environment before you rely on it.\n\nBefore you write anything, answer three questions:\n\n`EXISTS`\n\n). Enriching → join or window function.| Problem | Right pattern | Wrong pattern people reach for |\n|---|---|---|\n| \"Customers who have ≥1 order\" | `EXISTS` |\n`JOIN` + `DISTINCT`\n|\n| \"Customers with no orders\" | `NOT EXISTS` |\n`NOT IN` |\n| \"Each order plus its customer name\" | `JOIN` |\ncorrelated scalar subquery |\n| \"Total per customer\" | `GROUP BY` |\ncorrelated aggregate in SELECT |\n| \"Each order plus its customer's total\" | window function | correlated aggregate in SELECT |\n| \"Latest order per customer (full row)\" |\n`ROW_NUMBER()` or lateral |\n`MAX(date)` + self-join |\n| \"Top N per group\" |\n`ROW_NUMBER` /`RANK` /`DENSE_RANK`\n|\ncorrelated `COUNT` subquery |\n| \"Rows in A not in B, both large\" | anti-join (`NOT EXISTS` ) |\n`EXCEPT` /`MINUS` if you need dupes preserved |\n\n`DISTINCT`\n\nsmell `SELECT DISTINCT`\n\nin a query that joins to a child table almost always means you used a join where you wanted a semi-join. The join fans out, then `DISTINCT`\n\ncollapses it back — you paid for a sort/hash of the fanned-out set for nothing.\n\n```\n-- BAD: fan out 4M rows, then dedupe back to 200k\nSELECT DISTINCT c.customer_id, c.name\nFROM customer c JOIN orders o ON o.customer_id = c.customer_id\nWHERE o.status = 'SHIPPED';\n\n-- GOOD: optimizer stops at first match per customer\nSELECT c.customer_id, c.name\nFROM customer c\nWHERE EXISTS (SELECT 1 FROM orders o\n              WHERE o.customer_id = c.customer_id AND o.status = 'SHIPPED');\n```\n\nThere are legitimate `DISTINCT`\n\ns (deduping a genuinely duplicated source). But treat every one as a thing you must justify in code review.\n\nA junior writes a CTE thinking \"this runs once and gets reused.\" That is engine-dependent and version-dependent.\n\n| Engine | CTE behaviour |\n|---|---|\nPostgreSQL ≤ 11 |\nAlways materialized. Hard optimization fence — predicates do not push in. |\nPostgreSQL 12+ |\nInlined if referenced once and not recursive/volatile; otherwise materialized. Force with `WITH x AS MATERIALIZED (...)` / `AS NOT MATERIALIZED` . |\nSQL Server |\nEffectively always inlined — a CTE referenced three times is executed three times. No hint to materialize. Use a `#temp` table when you need one-shot evaluation. |\nOracle |\nCost-based; may materialize into a temp segment. Hints: `/*+ MATERIALIZE */` , `/*+ INLINE */` . |\nMySQL 8.0+ |\nMaterializes derived tables/CTEs in many cases; merge is possible. Historically weaker at pushing predicates into derived tables than the others. |\n\n**The scale gotcha:** on SQL Server, a \"clean\" query with a CTE that scans a 500M-row fact table and is referenced in three branches of a `UNION ALL`\n\nscans it three times. It looks elegant and it is a disaster. On PostgreSQL ≤ 11 the opposite failure: your `WHERE tenant_id = 42`\n\noutside the CTE never reaches inside it, so you materialize the whole table first.\n\n**Rule for the team:** if a CTE is referenced more than once *and* the underlying scan is expensive, on SQL Server/MySQL stage it into a temp table explicitly. Don't rely on the optimizer being clever.\n\nRecursive syntax differs cosmetically: PostgreSQL and MySQL require `WITH RECURSIVE`\n\n; SQL Server and Oracle use plain `WITH`\n\n(Oracle additionally has the older `CONNECT BY`\n\n, which is still often faster there for pure hierarchy walks and supports `LEVEL`\n\n, `SYS_CONNECT_BY_PATH`\n\n, `CONNECT_BY_ISLEAF`\n\n).\n\n`EXISTS`\n\n/ `NOT EXISTS`\n\n— and Why `NOT IN`\n\nKeeps Hurting You\n\n```\nSELECT * FROM customer\nWHERE customer_id NOT IN (SELECT customer_id FROM blacklist);\n```\n\nIf **one** row in `blacklist.customer_id`\n\nis NULL, this returns **zero rows**. Always. Silently.\n\nWhy: `x NOT IN (a, b, NULL)`\n\nexpands to `x <> a AND x <> b AND x <> NULL`\n\n. That last term is `UNKNOWN`\n\n, never `TRUE`\n\n. So the whole conjunction can never be `TRUE`\n\n.\n\n`NOT EXISTS`\n\ndoes not have this problem, because it tests row existence, not value comparison. **Default to NOT EXISTS.** If you must use\n\n`NOT IN`\n\n, the subquery column must be `NOT NULL`\n\n| Engine | `NOT EXISTS` |\n`NOT IN` |\n|---|---|---|\nOracle |\nAnti-join | Handles well — has a null-aware anti-join (`ANTI NA` ) operator (11g+). Roughly parity. |\nSQL Server |\nAnti semi-join | Usually anti-join too, but nullable columns add an extra probe/filter; plans get uglier. |\nPostgreSQL |\nClean anti-join | Historically cannot convert to an anti-join when the column is nullable — degrades to a filtered subplan. Materially worse on large sets. |\nMySQL 8.0.17+ |\nAnti-join transformation available | Also transformed in 8.0.17+. Pre-8.0 this was a per-row dependent subquery — catastrophic.\n|\n\nSo `NOT EXISTS`\n\nis correct everywhere *and* the best-performing choice on the two engines where it matters most. There is no reason to write `NOT IN`\n\nagainst a subquery. Against a short literal list (`status NOT IN ('A','B')`\n\n) it's fine.\n\n`LEFT JOIN ... WHERE right.pk IS NULL`\n\nThis is the third anti-join spelling. It is *semantically* safe (no NULL trap) but:\n\n`WHERE`\n\ninstead of `ON`\n\n, which silently converts your outer join to an inner join.Prefer `NOT EXISTS`\n\nfor readability; recognize the `LEFT JOIN / IS NULL`\n\nform when you meet it.\n\n`EXISTS`\n\nvs `IN`\n\nfor the positive case All four modernly transform `IN (subquery)`\n\ninto a semi-join, so they're usually equivalent. Two notes:\n\n`IN`\n\nwith a subquery returning NULLs is `NOT IN`\n\n) — NULLs just never match.`IN (subquery)`\n\nas a dependent subquery in many cases. If you maintain anything on 5.6/5.7, rewrite `IN`\n\nto `EXISTS`\n\nor a join. On 8.0+ the semijoin strategies (`FirstMatch`\n\n, `LooseScan`\n\n, `MaterializeLookup`\n\n, `DuplicateWeedout`\n\n) handle it.`EXISTS`\n\nmicro-details `SELECT 1`\n\nvs `SELECT *`\n\nvs `SELECT NULL`\n\ninside `EXISTS`\n\nmakes zero difference on any of the four — the select list isn't evaluated. Don't argue about it in review. Do make sure the correlation predicate is indexed; that's what actually matters.\n\nThis is the single most common performance defect in junior-written SQL, and it's identical on all four engines. **[SAME]**\n\n```\nSELECT o.order_id,\n       o.customer_id,\n       (SELECT COUNT(*) FROM orders o2\n        WHERE o2.customer_id = o.customer_id)      AS cust_order_count,\n       (SELECT SUM(amount) FROM orders o3\n        WHERE o3.customer_id = o.customer_id)      AS cust_total,\n       (SELECT MAX(order_date) FROM orders o4\n        WHERE o4.customer_id = o.customer_id)      AS cust_last_order\nFROM orders o\nWHERE o.order_date >= DATE '2026-01-01';\n```\n\nThree correlated subqueries = up to three extra index range scans **per output row**. At 2M output rows that's 6M extra scans. On a good day the optimizer decorrelates one of them. Don't rely on it.\n\n```\nSELECT order_id, customer_id,\n       COUNT(*)        OVER (PARTITION BY customer_id) AS cust_order_count,\n       SUM(amount)     OVER (PARTITION BY customer_id) AS cust_total,\n       MAX(order_date) OVER (PARTITION BY customer_id) AS cust_last_order\nFROM orders\nWHERE order_date >= DATE '2026-01-01';\n```\n\nOne scan, one sort/hash by `customer_id`\n\n, all three aggregates computed together. Note the semantic change: the window is now over the *filtered* set. If you need totals over all history, you can't use this form — see Fix 2.\n\n```\nWITH cust_agg AS (\n  SELECT customer_id,\n         COUNT(*)        AS cust_order_count,\n         SUM(amount)     AS cust_total,\n         MAX(order_date) AS cust_last_order\n  FROM orders\n  GROUP BY customer_id\n)\nSELECT o.order_id, o.customer_id, a.cust_order_count, a.cust_total, a.cust_last_order\nFROM orders o\nJOIN cust_agg a ON a.customer_id = o.customer_id\nWHERE o.order_date >= DATE '2026-01-01';\n```\n\nOne aggregate pass over the child, one hash join. This is the workhorse. **Critical rule: aggregate before you join, not after.** Joining first fans out rows and then you're aggregating an inflated set — which is also how you get silently wrong\n\n`SUM`\n\ns.\n\n```\n-- WRONG\nSELECT c.customer_id, SUM(o.amount), SUM(p.amount)\nFROM customer c\nJOIN orders o   ON o.customer_id = c.customer_id\nJOIN payments p ON p.customer_id = c.customer_id\nGROUP BY c.customer_id;\n```\n\nIf a customer has 3 orders and 4 payments, you get 12 rows; `SUM(o.amount)`\n\nis 4× too large and `SUM(p.amount)`\n\nis 3× too large. This ships to production regularly because the numbers *look* plausible. Fix: aggregate each branch separately in its own subquery/CTE, then join the two aggregates. Or use `COUNT(DISTINCT ...)`\n\nas a diagnostic to prove the fan-out to yourself.\n\n\"Give me each customer plus their most recent order's full details.\"\n\n| Engine | Syntax |\n|---|---|\n| PostgreSQL | `LEFT JOIN LATERAL (...) t ON true` |\n| Oracle 12c+ |\n`OUTER APPLY (...)` or `LEFT JOIN LATERAL (...) ON 1=1`\n|\n| SQL Server | `OUTER APPLY (...)` |\n| MySQL 8.0.14+ | `LEFT JOIN LATERAL (...) ON true` |\n\n```\n-- PostgreSQL / MySQL 8.0.14+\nSELECT c.customer_id, c.name, lo.order_id, lo.order_date, lo.amount\nFROM customer c\nLEFT JOIN LATERAL (\n    SELECT o.order_id, o.order_date, o.amount\n    FROM orders o\n    WHERE o.customer_id = c.customer_id\n    ORDER BY o.order_date DESC, o.order_id DESC\n    LIMIT 1\n) lo ON true;\n```\n\n(SQL Server/Oracle: `TOP 1`\n\n/ `FETCH FIRST 1 ROWS ONLY`\n\nand `OUTER APPLY`\n\n.)\n\n**When lateral beats ROW_NUMBER():** when the outer set is small and the inner table is huge and well-indexed. Lateral does N cheap index seeks.\n\n`ROW_NUMBER()`\n\nsorts the `ROW_NUMBER()`\n\nwins — one sorted pass beats 5M seeks.That trade-off — **N seeks vs. one big sort** — is the mental model. Know your N.\n\n```\nSELECT customer_id,\n       MAX(order_id) KEEP (DENSE_RANK LAST ORDER BY order_date, order_id) AS last_order_id,\n       MAX(amount)   KEEP (DENSE_RANK LAST ORDER BY order_date, order_id) AS last_amount,\n       SUM(amount) AS total\nFROM orders GROUP BY customer_id;\n```\n\n`KEEP (DENSE_RANK FIRST/LAST)`\n\ngets you \"the value of column X from the row with the max/min of Y\" inside a single `GROUP BY`\n\n— no window pass, no self-join. There is no direct equivalent on the other three; PostgreSQL's closest is `DISTINCT ON`\n\nor ordered-set aggregates.\n\n`ON`\n\nvs `WHERE`\n\non outer joins \n\n```\n-- These are NOT the same query\nFROM a LEFT JOIN b ON b.a_id = a.id AND b.status = 'X'   -- filters b before joining\nFROM a LEFT JOIN b ON b.a_id = a.id WHERE b.status = 'X' -- silently becomes an INNER JOIN\n```\n\nAny predicate on the null-supplying side placed in `WHERE`\n\neliminates the NULL-extended rows. If you genuinely want \"no matching b,\" write `WHERE b.a_id IS NULL`\n\n.\n\nWrapping an indexed column in a function kills the index. Universal.\n\n```\nWHERE UPPER(email) = 'A@B.COM'          -- no index use on email\nWHERE order_date + 1 > SYSDATE          -- no index use\nWHERE YEAR(order_date) = 2026           -- no index use\n```\n\nRemedies by engine:\n\n| Engine | Expression/functional index | Case-insensitive strategy |\n|---|---|---|\n| PostgreSQL |\n`CREATE INDEX ON t (upper(email))` — long supported |\n`citext` type, or expression index |\n| Oracle | Function-based indexes (8i+) | FBI on `UPPER()` , or `NLS_SORT` /`NLS_COMP` linguistic index |\n| SQL Server | No true expression index — use a persisted computed column + index |\nColumn collation is usually CI already (`_CI_AS` ) so it's often a non-issue |\n| MySQL 8.0.13+ | Functional indexes; before that, generated column + index | Default collations are CI (`utf8mb4_0900_ai_ci` ) — usually a non-issue |\n\n**Two engine-specific date traps:**\n\n`DATE`\n\nalways carries a time component. `WHERE order_date = DATE '2026-07-27'`\n\nmisses everything with a nonzero time. Use a half-open range `>= DATE '2026-07-27' AND < DATE '2026-07-28'`\n\n, not `TRUNC()`\n\n.`datetime`\n\n(legacy) has ~3ms precision and rounds `.997`\n\n→`.997`\n\n, `.999`\n\n→next second. `BETWEEN '2026-07-01' AND '2026-07-31 23:59:59.999'`\n\nis a bug. Always use half-open ranges. This applies everywhere but bites hardest here.`NVARCHAR`\n\nparameters against a `VARCHAR`\n\ncolumn forces `CONVERT_IMPLICIT`\n\non the `NVARCHAR`\n\nwins. This is probably the #1 cause of \"it's fast in SSMS but slow from the app.\" Match your parameter types to your column types.`utf8mb3_general_ci`\n\nlegacy table to `utf8mb4_0900_ai_ci`\n\nnew table) prevents index use on the join. Also, comparing a string column to a numeric literal converts the `VARCHAR2`\n\nvs `NVARCHAR2`\n\n, and comparing a `NUMBER`\n\ncolumn to a string literal. Oracle converts the character side to number, which is safe, but comparing a `VARCHAR2`\n\ncolumn to a number converts the column → scan.`bigint`\n\ncolumn vs `int`\n\nparameter is fine while `text`\n\nvs `varchar`\n\ncollation mismatches can still block index use in less common cases.`GROUP BY`\n\nstrictness | Engine | Rule |\n|---|---|\n| SQL Server, Oracle | Strict ANSI. Every non-aggregated select item must be in `GROUP BY` . |\n| PostgreSQL | Strict, except it recognizes functional dependency on a grouped primary key — `GROUP BY c.customer_id` lets you select `c.name` . |\n| MySQL |\n`ONLY_FULL_GROUP_BY` is on by default since 5.7.5. Similar functional-dependency detection to PG. Legacy code with it disabled returns arbitrary values from arbitrary rows — a correctness bug, not a style issue. Never turn it off. |\n\n`HAVING`\n\nvs `WHERE`\n\n`WHERE`\n\nfilters rows before grouping; `HAVING`\n\nfilters groups after. Putting a non-aggregate predicate in `HAVING`\n\nis not *wrong* (all four will typically push it down) but it's misleading. Put row filters in `WHERE`\n\n.\n\n```\n-- Works everywhere\nSELECT customer_id,\n       SUM(CASE WHEN status = 'SHIPPED'   THEN amount ELSE 0 END) AS shipped_amt,\n       COUNT(CASE WHEN status = 'CANCELLED' THEN 1 END)           AS cancelled_cnt\nFROM orders GROUP BY customer_id;\n```\n\nPostgreSQL additionally has the cleaner ANSI `FILTER`\n\nclause: `COUNT(*) FILTER (WHERE status = 'CANCELLED')`\n\n. It's PG-only among these four. Use it in PG-only code; use `CASE`\n\nin anything portable.\n\nNote `COUNT(CASE WHEN ... THEN 1 END)`\n\n— no `ELSE`\n\n, so non-matches are NULL and `COUNT`\n\nskips them. `COUNT(CASE WHEN ... THEN 1 ELSE 0 END)`\n\ncounts everything and is a classic bug.\n\n| Operation | PG | MySQL | SQL Server | Oracle |\n|---|---|---|---|---|\n| Union, dedupe | `UNION` |\n`UNION` |\n`UNION` |\n`UNION` |\n| Union, keep dupes | `UNION ALL` |\n`UNION ALL` |\n`UNION ALL` |\n`UNION ALL` |\n| Difference | `EXCEPT` |\n`EXCEPT` (8.0.31+) |\n`EXCEPT` |\n`MINUS` (`EXCEPT` added in 21c+) |\n| Intersection | `INTERSECT` |\n`INTERSECT` (8.0.31+) |\n`INTERSECT` |\n`INTERSECT` |\n\n**Always ask whether you need UNION or UNION ALL.**\n\n`UNION`\n\nperforms a full dedupe (sort or hash) over the combined result. If the branches are provably disjoint — different date ranges, different status values — `UNION ALL`\n\nis free and `UNION`\n\ncosts you a sort of the entire set. On a 200M-row report this is the difference between 40 seconds and 8 minutes.Also: `EXCEPT`\n\n/`MINUS`\n\ndeduplicate the left side. `NOT EXISTS`\n\ndoes not. They are not interchangeable when duplicates are meaningful.\n\n| Algorithm | PG | MySQL 8.0 | SQL Server | Oracle |\n|---|---|---|---|---|\n| Nested loop | ✅ | ✅ | ✅ | ✅ |\n| Hash join | ✅ | ✅ (8.0.18+) | ✅ | ✅ |\n| Merge join | ✅ | ❌ never\n|\n✅ | ✅ (sort-merge) |\n\n**MySQL's gap is the important one.** Before 8.0.18 MySQL had *only* nested-loop variants (`BNL`\n\n). A three-table analytical join over millions of rows that any other engine resolves with hash joins in seconds could run for hours on MySQL 5.7. If you're on MySQL and a large join is slow, check the version first — this is frequently the whole answer. And MySQL still has no merge join, so pre-sorted large-to-large joins don't get that option.\n\nPractical consequence: **MySQL is a weaker analytical engine than the other three.** Design around it — pre-aggregate into summary tables rather than expecting the optimizer to rescue a 6-table star join.\n\n| Engine | Basic windows | Full frames (`ROWS` /`RANGE` ) |\n`GROUPS` / `EXCLUDE`\n|\n|---|---|---|---|\n| Oracle | 8i (ancient, very mature) | ✅ |\n`GROUPS` in 21c+ |\n| SQL Server | 2005 (ranking only) | 2012+ |\n❌ not supported |\n| PostgreSQL | 8.4 | ✅ | ✅ (11+) |\n| MySQL | 8.0 only |\n✅ | ❌ |\n\n`ROW_NUMBER`\n\n, `RANK`\n\n, `DENSE_RANK`\n\n, `NTILE`\n\n, `LAG`\n\n, `LEAD`\n\n, `FIRST_VALUE`\n\n, `LAST_VALUE`\n\n, `SUM/AVG/COUNT/MIN/MAX OVER`\n\nare present and identical in semantics on all four modern versions. **[SAME]**\n\nFor values `100, 100, 90, 80`\n\n:\n\n| Function | Result | Use when |\n|---|---|---|\n`ROW_NUMBER()` |\n1, 2, 3, 4 | You need exactly one row per group — deduplication, \"latest record\" |\n`RANK()` |\n1, 1, 3, 4 | Competition ranking; gaps after ties |\n`DENSE_RANK()` |\n1, 1, 2, 3 | \"Top 3 distinct salary levels\"; no gaps |\n\n**The classic requirement mismatch:** \"give me the top 3 earners per department.\" If two people tie for 3rd, do you want 3 rows or 4? `ROW_NUMBER`\n\ngives 3 (arbitrarily dropping one — nondeterministic and a real bug). `RANK`\n\n/`DENSE_RANK`\n\ngive 4. Ask the business. Then make the tiebreaker explicit either way.\n\n**Always add a deterministic tiebreaker to ORDER BY inside the window.**\n\n`ORDER BY order_date DESC`\n\non a table with same-day orders produces different results run to run. Write `ORDER BY order_date DESC, order_id DESC`\n\n. This is the source of most \"the report changed but the data didn't\" tickets.\n\n```\nWITH ranked AS (\n  SELECT t.*,\n         ROW_NUMBER() OVER (PARTITION BY natural_key\n                            ORDER BY loaded_at DESC, id DESC) AS rn\n  FROM staging t\n)\nSELECT * FROM ranked WHERE rn = 1;\n```\n\nThe CTE wrapper is required — you cannot filter on a window function in `WHERE`\n\non any of these four, because windows are evaluated after `WHERE`\n\n. **None of PostgreSQL, MySQL, SQL Server, or Oracle support QUALIFY** (that's Teradata/Snowflake/DuckDB). Expect to write the CTE.\n\nPostgreSQL has a shorthand for the `rn = 1`\n\ncase:\n\n```\nSELECT DISTINCT ON (natural_key) *\nFROM staging ORDER BY natural_key, loaded_at DESC, id DESC;\n```\n\nPG-only. Often faster than `ROW_NUMBER`\n\nthere.\n\nWhen you write `OVER (PARTITION BY x ORDER BY y)`\n\nwith no frame clause, the implicit frame is `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`\n\n.\n\n`RANGE`\n\nmeans **peer rows (ties in y) are all included at once**. So a \"running total\" over a day-grain column gives every row on the same date the\n\n```\n-- What you almost always meant:\nSUM(amount) OVER (PARTITION BY customer_id\n                  ORDER BY order_date, order_id\n                  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)\n```\n\n**SQL Server–specific performance angle:** in SQL Server the default `RANGE`\n\nframe uses an **on-disk** worktable spool, while `ROWS`\n\nuses an in-memory spool. Specifying `ROWS`\n\nexplicitly is frequently a 5–10× improvement on large partitions — even when the results would be identical. Make `ROWS ...`\n\nmandatory in SQL Server code review.\n\n`LAST_VALUE`\n\ntrap [SAME]:`LAST_VALUE(x) OVER (ORDER BY y)`\n\nreturns the current row's value, because the default frame ends at the current row. You need `ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING`\n\n, or better, use `FIRST_VALUE`\n\nwith the order reversed.\n\nPostgreSQL, MySQL 8.0, and Oracle support `WINDOW w AS (PARTITION BY ... ORDER BY ...)`\n\nthen `OVER w`\n\n. **SQL Server does not** — you repeat the full `OVER`\n\nclause. Minor, but it's a portability paper cut and a source of copy-paste inconsistency in SQL Server code.\n\n`(partition_cols, order_cols)`\n\nlets PostgreSQL, Oracle, and SQL Server skip the sort entirely. This is often the single highest-leverage index on a reporting table.`ORDER BY`\n\ns = three sorts. Consolidate window definitions where you can.If you only need aggregates at the group grain, `GROUP BY`\n\nis cheaper — it collapses rows; windows preserve them. Don't write `SELECT DISTINCT customer_id, SUM(x) OVER (PARTITION BY customer_id)`\n\n; write `GROUP BY`\n\n.\n\nJSON columns are for **genuinely schemaless, sparse, or third-party-shaped payloads**: webhook bodies, per-tenant custom fields, audit snapshots, API response caching. They are *not* a substitute for columns.\n\nThe test: **if you filter, join, sort, or aggregate on it regularly, it should be a column.** Every engine's JSON indexing story is worse than a plain B-tree on a scalar column. You also lose type checking, NOT NULL, foreign keys, and check constraints.\n\nSecond rule: **do not use JSON as an EAV escape hatch.** \"Just put the extra attributes in a JSON blob\" is how you get a 400GB table where nobody knows what's in it and every query is a full scan.\n\n| PostgreSQL | MySQL 8.0 | SQL Server | Oracle | |\n|---|---|---|---|---|\n| Native type |\n`json` (text) / (binary)`jsonb` |\n`JSON` (binary) |\n`NVARCHAR(MAX)` + `ISJSON` check (native `json` type in SQL Server 2025) |\n`JSON` type in 21c+; before that `CLOB` /`VARCHAR2` + `IS JSON`\n|\n| Extract scalar |\n`->>` , `#>>` , `jsonb_path_query`\n|\n`->>` , `JSON_UNQUOTE(JSON_EXTRACT())` , `JSON_VALUE` (8.0.21+) |\n`JSON_VALUE` |\n`JSON_VALUE` , dot notation |\n| Extract object/array |\n`->` , `#>`\n|\n`->` |\n`JSON_QUERY` |\n`JSON_QUERY` |\n| Containment test |\n`@>` (indexed!) |\n`JSON_CONTAINS` |\n`JSON_PATH_EXISTS` (2022+) |\n`JSON_EXISTS` |\n| Shred to rows |\n`jsonb_array_elements` , `JSON_TABLE` (17+) |\n`JSON_TABLE` (8.0.4+) |\n`OPENJSON` |\n`JSON_TABLE` |\n| Modify in place |\n`jsonb_set` , ` |\n`, ` -` |\n`JSON_SET/INSERT/REPLACE/REMOVE` |\n|\nGeneral index |\nGIN on — indexes all keys/values at once`jsonb` |\n❌ none | ❌ (until 2025's JSON indexes) |\nJSON search index (`CREATE SEARCH INDEX ... FOR JSON` ) |\n| Targeted index | B-tree on expression | Generated column + index; multi-valued index for arrays (8.0.17+) |\nPersisted computed column + index | Function-based index on `JSON_VALUE`\n|\n\n`jsonb`\n\n, essentially always\n`json`\n\nstores the raw text (preserves whitespace, key order, duplicate keys) and re-parses on every access. `jsonb`\n\nis parsed binary — faster access, supports indexing and containment operators. Cost: slightly slower insert, key order not preserved, duplicate keys collapsed.\n\n```\nCREATE INDEX idx_doc_gin ON events USING GIN (payload jsonb_path_ops);\n-- supports: WHERE payload @> '{\"type\":\"purchase\"}'\n\n-- Narrower + smaller when you know the key:\nCREATE INDEX idx_doc_type ON events ((payload->>'type'));\n```\n\n`jsonb_path_ops`\n\nGIN indexes are substantially smaller and faster than the default `jsonb_ops`\n\nbut only support `@>`\n\n/ `@?`\n\n/ `@@`\n\n, not key-existence (`?`\n\n). Prefer it unless you need key-existence checks.\n\n**PG gotchas at scale:** large `jsonb`\n\nvalues get TOASTed (out-of-line, compressed), so `payload->>'x'`\n\non a 2KB document means detoast + decompress *per row*. Extracting one small field from a big document across millions of rows is far slower than people expect. Also, `jsonb_set`\n\nrewrites the entire value — updating one key in a 100KB document writes 100KB and generates that much WAL.\n\nYou cannot `CREATE INDEX ON t (json_col->>'$.status')`\n\ndirectly in the way you'd hope. Two supported routes:\n\n```\n-- Route 1: generated column (works 5.7+)\nALTER TABLE events\n  ADD COLUMN evt_type VARCHAR(50)\n    AS (JSON_UNQUOTE(JSON_EXTRACT(payload,'$.type'))) STORED,\n  ADD INDEX idx_evt_type (evt_type);\n\n-- Route 2: functional index (8.0.13+) — same thing, less clutter\nALTER TABLE events ADD INDEX idx_evt_type ((CAST(payload->>'$.type' AS CHAR(50))));\n\n-- Route 3: multi-valued index for arrays (8.0.17+)\nALTER TABLE events ADD INDEX idx_tags ((CAST(payload->'$.tags' AS CHAR(40) ARRAY)));\n-- used by: WHERE JSON_CONTAINS(payload->'$.tags', '\"urgent\"')\n```\n\nMulti-valued indexes are MySQL's one genuinely nice JSON feature — one index entry per array element, queryable via `MEMBER OF`\n\n, `JSON_CONTAINS`\n\n, `JSON_OVERLAPS`\n\n.\n\n**MySQL gotchas:** JSON columns can't be used in a primary key; generated columns must be `STORED`\n\n(not `VIRTUAL`\n\n) for some index types; partial in-place updates only happen under narrow conditions (`JSON_SET`\n\non same-or-smaller value) — otherwise MySQL rewrites the whole document and logs it whole to the binlog. Replication lag from JSON-heavy writes is a real production issue.\n\nThrough SQL Server 2022, JSON is `NVARCHAR(MAX)`\n\n— there was no JSON type, no JSON index. You get it via:\n\n```\nALTER TABLE Events ADD EventType AS\n  JSON_VALUE(Payload, '$.type') PERSISTED;\nCREATE INDEX IX_Events_Type ON Events (EventType);\n```\n\n`OPENJSON`\n\nwith an explicit `WITH`\n\nschema is dramatically faster than the default key/value form, and gives you typed columns:\n\n```\nSELECT j.type, j.amount\nFROM Events e\nCROSS APPLY OPENJSON(e.Payload)\n  WITH (type VARCHAR(50) '$.type', amount DECIMAL(18,2) '$.amount') j;\n```\n\nAdd `CHECK (ISJSON(Payload) = 1)`\n\non the column or you *will* accumulate malformed rows.\n\nSQL Server 2025 introduced a native `json`\n\ntype with binary storage and JSON indexes; if you're on it, prefer that over `NVARCHAR(MAX)`\n\n. Confirm the specifics against the docs for your build — this is recent.\n\nOracle's JSON support (especially 19c+ and 21c/23ai) is arguably the most complete:\n\n```\n-- Constraint on pre-21c storage\nALTER TABLE events ADD CONSTRAINT ck_json CHECK (payload IS JSON);\n\n-- Dot notation (requires table alias)\nSELECT e.payload.type, e.payload.amount FROM events e;\n\n-- General-purpose index over the whole document\nCREATE SEARCH INDEX idx_payload ON events (payload) FOR JSON;\n\n-- Targeted\nCREATE INDEX idx_type ON events (JSON_VALUE(payload, '$.type'\n  RETURNING VARCHAR2(50) ERROR ON ERROR NULL ON EMPTY));\n```\n\nThe `RETURNING`\n\nclause and error-handling clauses must match exactly between index definition and query predicate or the index isn't used — a common and frustrating gotcha.\n\nOracle 23ai adds **JSON Relational Duality Views**: relational tables underneath, JSON document API on top, with proper concurrency control. Genuinely the best answer to \"the app team wants documents, I want normalization,\" if you're on that version.\n\nIf you must support more than one engine: use JSON as an **opaque payload** — store it, retrieve it whole, parse in the application. The moment you query *inside* it, you've committed to that engine. Extract the fields you query into real columns at write time.\n\n`(a, b, c)`\n\nserves predicates on `a`\n\n, `(a,b)`\n\n, `(a,b,c)`\n\n. It does `b`\n\nalone. All four can do a full index scan in that case, but that's not what you designed for.`WHERE tenant_id = ? AND status = ? AND created_at > ?`\n\n, index `(tenant_id, status, created_at)`\n\n. Putting `created_at`\n\nbefore `status`\n\nmeans the index can only seek to the range start and must filter the rest.| Engine | Table storage | Consequence |\n|---|---|---|\nMySQL/InnoDB |\nAlways clustered on the PK (or a hidden rowid if none). Secondary indexes store the PK value as the row pointer. |\nA wide PK (e.g. `UUID CHAR(36)` ) is duplicated into every secondary index. Random PKs cause page splits on every insert. |\nSQL Server |\nClustered index optional; without one the table is a heap. Nonclustered indexes point to the clustering key (or RID for heaps). |\nSame PK-width amplification as InnoDB when clustered. Heaps suffer forwarded records on updates. |\nPostgreSQL |\nAlways a heap. No clustered index. `CLUSTER` is a one-time reorganization that doesn't hold. |\nPhysical order drifts; correlation matters for range scan cost. Index-only scans need the visibility map to be current. |\nOracle |\nHeap by default; Index-Organized Tables available for PK-clustered storage. |\nIOTs are great for narrow lookup tables, less so for wide ones. |\n\n**The PK choice consequence:** on MySQL and SQL Server, use a **narrow, monotonically increasing** clustering key. `BIGINT`\n\nidentity/auto_increment is the default correct answer. Random `UUID`\n\nv4 as a clustered PK on InnoDB is a well-documented performance disaster — insert throughput collapses as the B-tree fragments and every secondary index carries 16–36 bytes per entry.\n\nIf you need UUIDs for distributed ID generation, use a **time-ordered** variant (UUIDv7, or SQL Server's `NEWSEQUENTIALID()`\n\n, or ULID) stored as `BINARY(16)`\n\n/ `uniqueidentifier`\n\n/ `uuid`\n\n— never as a 36-char string. PostgreSQL tolerates random UUIDs better (heap storage) but still suffers index bloat and worse cache locality.\n\n| Feature | PG | MySQL 8.0 | SQL Server | Oracle |\n|---|---|---|---|---|\n| Partial / filtered index | ✅ `WHERE`\n|\n❌ | ✅ filtered index | ❌ (emulate: function-based index returning NULL — Oracle doesn't index all-NULL entries) |\n| Covering non-key columns | ✅ `INCLUDE` (11+) |\n❌ (add as key cols) | ✅ `INCLUDE`\n|\n❌ (add as key cols) |\n| Expression / functional index | ✅ | ✅ (8.0.13+) | ⚠️ via persisted computed column | ✅ |\n| Descending index | ✅ | ✅ (8.0+; earlier it was parsed and ignored) | ✅ | ✅ |\n| Bitmap index | ❌ | ❌ | ⚠️ columnstore serves the use case | ✅ (DW only) |\n| Columnstore | ⚠️ extensions | ❌ | ✅ (mature) | ✅ In-Memory Column Store (licensed) |\n| Hash index | ✅ (limited) | ✅ MEMORY engine only | ✅ (in-memory OLTP) | ✅ hash cluster |\n| GIN / inverted (arrays, JSON, FTS) | ✅ | ⚠️ multi-valued only | ❌ | ✅ search index |\n| BRIN (huge, correlated tables) | ✅ | ❌ | ⚠️ columnstore segment elimination | ⚠️ zone maps (Exadata) |\n| Invisible index (test before drop) | ⚠️ no | ✅ | ⚠️ via disable | ✅ |\n| Online index build | ✅ `CONCURRENTLY`\n|\n✅ mostly | ✅ Enterprise only | ✅ `ONLINE` (EE) |\n\n**Three engine-specific traps worth memorizing:**\n\n`WHERE deleted_at IS NULL`\n\ncannot use a single-column index on `deleted_at`\n\n. Workaround: make it composite (`(deleted_at, id)`\n\n) or use a function-based index. `''`\n\nas NULL.`col = ''`\n\nwill silently return nothing.\n\n```\n-- PostgreSQL\nCREATE INDEX idx_orders_pending ON orders (created_at)\nWHERE status = 'PENDING';\n\n-- SQL Server\nCREATE INDEX IX_Orders_Pending ON Orders (CreatedAt)\nWHERE Status = 'PENDING';\n```\n\nIf 0.1% of a 500M-row table is pending, this index is ~500k entries instead of 500M. Enormous. **MySQL has no equivalent** — the standard workaround is a separate \"work queue\" table that rows are deleted from when processed. That's not a hack; on MySQL it's the right design.\n\n**SQL Server filtered-index gotcha:** the plan only uses it if the parameter is provably within the filter. Parameterized queries (`WHERE Status = @s`\n\n) often *won't* use a filtered index because the optimizer can't prove `@s = 'PENDING'`\n\nat compile time. `OPTION (RECOMPILE)`\n\nor a literal fixes it. This surprises people constantly.\n\n| Engine | Mechanism | Main failure mode |\n|---|---|---|\nSQL Server |\nAuto-update stats on ~20% change (or sqrt-based on 2016+). Plans cached per parameterized statement. |\nParameter sniffing — the first execution's parameter values shape the cached plan for everyone. A plan built for `tenant_id = <tiny tenant>` is then used for `<huge tenant>` . Remedies: `OPTION (RECOMPILE)` , `OPTIMIZE FOR` , Query Store forced plans, Automatic Tuning. |\nOracle |\nBind peeking + Adaptive Cursor Sharing + SQL Plan Baselines. | Adaptive features mostly help but can cause plan churn. Baselines/SQL Profiles are the stability tool. Stale stats on volatile tables still bite. |\nPostgreSQL |\nAutovacuum/autoanalyze. Prepared statements switch to a generic plan after 5 executions if it seems no worse. | Skewed data + generic plan = bad plan. `plan_cache_mode = force_custom_plan` is the escape hatch. Also: no cross-column statistics by default — use `CREATE STATISTICS` for correlated columns (`city` /`postcode` ). |\nMySQL |\nSimplest optimizer of the four. Histograms only since 8.0. No plan cache to speak of. | Weakest cardinality estimation; InnoDB index statistics are sampled (`innodb_stats_persistent_sample_pages` ) and can be badly wrong on large tables. Raise the sample pages on big tables. |\n\n**Team practice:** on SQL Server, when someone says \"the app is slow but it's fast when I run it in SSMS,\" your first hypothesis is parameter sniffing, second is implicit conversion from NVARCHAR parameters. Those two cover the majority of cases.\n\n`LIMIT 20 OFFSET 500000`\n\n(or `OFFSET ... FETCH`\n\n, or `ROWNUM`\n\n/`ROW_NUMBER`\n\nfiltering) makes the engine produce and discard 500,000 rows. Cost grows linearly with page number. Use **keyset (seek) pagination**:\n\n```\nSELECT ... FROM orders\nWHERE (created_at, order_id) < (:last_created_at, :last_order_id)\nORDER BY created_at DESC, order_id DESC\nFETCH FIRST 20 ROWS ONLY;\n```\n\nRow-value comparison works in PostgreSQL, MySQL, and Oracle. **SQL Server does not support row-value constructors in comparisons** — expand manually:\n\n```\nWHERE created_at < @last_created_at\n   OR (created_at = @last_created_at AND order_id < @last_order_id)\n```\n\n(SQL Server may need an index hint or the rewritten `OR`\n\nform to seek properly; check the plan.)\n\nGet to **3NF/BCNF** and stay there unless you have a measured reason not to. Normalization gives you: one place to update each fact, constraints that actually work, smaller rows (more rows per page, better cache hit rate), and a schema that survives requirement changes.\n\nDenormalization is a **performance optimization with a correctness cost**. You are trading guaranteed consistency for speed. Like all optimizations: measure first, and know how you'll maintain it.\n\nDenormalize derived data freely with a rebuild path. Duplicate raw data only with a very clear write-path story.\n\n**1. Immutable historical snapshots — not really denormalization at all.**\n\nAn order line must store `unit_price`\n\nand `product_name`\n\n**as of the transaction**. This is not a redundant copy of `product.price`\n\n— it's a different fact (\"what we charged\") from a different point in time. Joining to `product`\n\nfor historical price is a *bug*, not an optimization. Snapshot anything that appears on a legal or financial document.\n\n**2. Counter caches / rollups.**\n\n`post.comment_count`\n\ninstead of `COUNT(*)`\n\non every page load. Worth it when read:write is heavily skewed.\n\n*Gotcha at scale:* every comment insert updates the same post row. A hot post becomes a lock convoy. Mitigations: sharded counters (N rows per post, summed), async batch updates, or accept eventual consistency and recompute nightly.\n\n**3. Precomputed aggregate/summary tables.**\n\nDaily/hourly rollups for dashboards. Almost always the right answer for reporting on a large OLTP table. This is the highest-value denormalization pattern in practice, and the one juniors underuse most.\n\n**4. Flattening a hot join to avoid a two-hop lookup.**\n\nStoring `tenant_id`\n\non every table in a multi-tenant system — even where it's derivable through a parent — so that every index can lead with it and every query can filter on it directly. This is standard, correct multi-tenant design, and it also lets row-level security work cleanly.\n\n**5. Materialized path / nested sets for deep hierarchies.**\n\nRecursive CTEs are fine for shallow trees. For an org chart read thousands of times a second, store a materialized path (`/1/17/93/`\n\n) alongside the parent FK. Redundant, but a prefix `LIKE '/1/17/%'`\n\non an indexed column beats a recursion.\n\n| Engine | Mechanism | Notes |\n|---|---|---|\nOracle |\nMaterialized views with query rewrite, `FAST REFRESH ON COMMIT` , materialized view logs |\nBest in class. Optimizer transparently rewrites queries to use the MV — the application doesn't change. |\nSQL Server |\nIndexed views (`WITH SCHEMABINDING` + unique clustered index) |\nMaintained synchronously and always correct. Auto-matched by the optimizer in Enterprise Edition; other editions need `NOEXPAND` . Heavy restrictions: no outer joins, no subqueries, no `COUNT(*)` (use `COUNT_BIG(*)` ), `SUM` requires a `COUNT_BIG` column. |\nPostgreSQL |\nMaterialized views — manual `REFRESH MATERIALIZED VIEW`\n|\nNo incremental refresh, no automatic query rewrite. `REFRESH ... CONCURRENTLY` avoids blocking readers but requires a unique index and is slower. For incremental, roll your own with triggers. |\nMySQL |\nNothing. No materialized views, no indexed views. |\nYou build summary tables yourself with triggers, scheduled events, or application logic. Plan for this from day one on MySQL. |\n\n**Consequence for design:** on Oracle you can often denormalize *invisibly* via MVs with query rewrite — the logical model stays clean. On MySQL every denormalization is explicit, hand-maintained, and a permanent maintenance obligation. Weight the trade-off accordingly: the same denormalization is much cheaper on Oracle than on MySQL.\n\nThree approaches, in order of preference:\n\n**Always build #3 regardless of whether you have #1 or #2.** A denormalization without a drift-detection job is a future incident. Make \"how do we detect and repair drift\" a mandatory question in design review.\n\nNote also: `CHECK`\n\nconstraints in MySQL were parsed and ignored until 8.0.16. Any schema older than that may have constraints that never did anything. Verify.\n\n| Engine | Auto-indexes FK columns? |\n|---|---|\nMySQL/InnoDB |\nYes — automatically creates an index on the child column if one doesn't exist |\nPostgreSQL |\nNo |\nSQL Server |\nNo |\nOracle |\nNo |\n\nThree of four leave you to it. Unindexed FKs cause:\n\n`DELETE`\n\n/`UPDATE`\n\non the parent (must scan children to enforce the constraint)**The Oracle-specific severity:** an unindexed foreign key causes Oracle to take a **share lock on the entire child table** during parent deletes and PK updates. Not a row lock — a table lock. On a busy system this is an outage. This is one of the classic Oracle DBA findings, and it's worth running a \"find unindexed FKs\" audit query on any Oracle schema you inherit.\n\n**Rule: every FK column gets an index, on every engine, unless you can prove it's never traversed and the parent is never deleted from.**\n\n`ON DELETE CASCADE`\n\nSupported on all four. Caveats:\n\nScenario: `Vehicle`\n\nwith subtypes `Car`\n\n, `Truck`\n\n, `Motorcycle`\n\n, each with distinct attributes.\n\n**A. Single Table Inheritance** — one table, all columns, a `vehicle_type`\n\ndiscriminator.\n\n```\nCREATE TABLE vehicle (\n  vehicle_id    BIGINT PRIMARY KEY,\n  vehicle_type  VARCHAR(20) NOT NULL,\n  vin           VARCHAR(17) NOT NULL,\n  -- car only\n  passenger_cnt INT,\n  -- truck only\n  payload_kg    INT,\n  CONSTRAINT ck_car CHECK (vehicle_type <> 'CAR' OR\n                           (passenger_cnt IS NOT NULL AND payload_kg IS NULL))\n);\n```\n\n`NOT NULL`\n\nimpossible on subtype attributes, degrades badly past ~4 subtypes or when subtypes diverge.Use when: subtypes are few, similar, and attributes are mostly shared.\n\n**B. Class Table Inheritance** — common attributes in parent, specifics in child tables sharing the PK.\n\n```\nCREATE TABLE vehicle (\n  vehicle_id   BIGINT PRIMARY KEY,\n  vehicle_type VARCHAR(20) NOT NULL,\n  vin          VARCHAR(17) NOT NULL UNIQUE,\n  CONSTRAINT ck_type CHECK (vehicle_type IN ('CAR','TRUCK','MOTORCYCLE')),\n  CONSTRAINT uq_vehicle_type UNIQUE (vehicle_id, vehicle_type)   -- key trick\n);\n\nCREATE TABLE car (\n  vehicle_id    BIGINT PRIMARY KEY,\n  vehicle_type  VARCHAR(20) NOT NULL DEFAULT 'CAR',\n  passenger_cnt INT NOT NULL,\n  CONSTRAINT ck_car_type CHECK (vehicle_type = 'CAR'),\n  CONSTRAINT fk_car_vehicle FOREIGN KEY (vehicle_id, vehicle_type)\n    REFERENCES vehicle (vehicle_id, vehicle_type)\n);\n```\n\nThat composite FK + check constraint pattern is the **standard way to make subtype exclusivity declaratively enforced** — a row in `vehicle`\n\ntyped `CAR`\n\ncannot have a `truck`\n\nrow. It works on all four engines. Learn it; most people implement this with application logic and get it wrong.\n\n`NOT NULL`\n\non subtype attributes, clean model, no sparse columns.\n− Every full read needs a join; \"give me all vehicles with all attributes\" needs a `UNION ALL`\n\nof per-subtype joins or several `LEFT JOIN`\n\ns.Use when: subtypes have substantial distinct attributes. **This is the default correct answer for most business domains.**\n\n**C. Concrete Table Inheritance** — one independent table per subtype, no parent.\n\n`UNION ALL`\n\neverywhere. Adding a subtype means touching every polymorphic query.Use when: subtypes barely overlap and are rarely queried together.\n\n```\n-- DON'T\nCREATE TABLE comment (\n  comment_id     BIGINT PRIMARY KEY,\n  commentable_id BIGINT,        -- points to post OR photo OR video\n  commentable_type VARCHAR(20)\n);\n```\n\nPopular in Rails/Django-shaped codebases. You **cannot declare a foreign key**, so referential integrity is gone. Joins need `CASE`\n\nor `UNION`\n\n. Indexes are less selective.\n\nBetter: an exclusive-arc table with one nullable FK per target plus a check that exactly one is non-null; or a shared `commentable`\n\nsupertype table that each concrete type FKs into. The latter is more work up front and correct forever.\n\n`INHERITS`\n\n) exists but is largely a legacy feature. Its traps: unique constraints and foreign keys **Pick the narrowest type that will hold the domain, and pick it correctly the first time.** Changing a PK from `INT`\n\nto `BIGINT`\n\non a 2-billion-row table is a multi-hour maintenance window on most of these engines.\n\n`INT`\n\nvs `BIGINT`\n\n:`BIGINT`\n\nfrom day one. The 4 extra bytes cost far less than the migration. Running out of `INT`\n\nidentity values in production is a rite of passage you should skip.`DECIMAL`\n\n/`NUMBER`\n\nalways. Never `FLOAT`\n\n/`REAL`\n\n/`DOUBLE`\n\n. `0.1 + 0.2 <> 0.3`\n\nin binary floating point, and financial reconciliation will find it.`BOOLEAN`\n\n. MySQL's `BOOLEAN`\n\nis an alias for `TINYINT(1)`\n\n— it accepts any integer. SQL Server has `BIT`\n\n(0/1/NULL, not a true boolean; can't be used directly in `WHERE x`\n\nwithout comparison). `NUMBER(1)`\n\nor `CHAR(1)`\n\nwith a check constraint.`varchar(n)`\n\n/`text`\n\nhave identical performance — the length limit is purely a constraint, so use `text`\n\n+ a check if you like. MySQL's `VARCHAR`\n\nlength affects temp table and index size, and index prefix limits apply. SQL Server: `NVARCHAR`\n\nis 2 bytes/char (pre-2019 non-UTF8 collations); `CHAR(n)`\n\nblank-pads. `VARCHAR2`\n\nis the one to use (`VARCHAR`\n\nis reserved and may change meaning); default `NLS_LENGTH_SEMANTICS`\n\nis BYTE, so `VARCHAR2(50)`\n\nmay hold fewer than 50 multibyte characters — specify `VARCHAR2(50 CHAR)`\n\n.`timestamptz`\n\n(stores UTC, converts on display) — use it, not `timestamp`\n\n. Oracle: `TIMESTAMP WITH TIME ZONE`\n\nor `WITH LOCAL TIME ZONE`\n\n. SQL Server: `datetime2`\n\n(not `datetime`\n\n), `datetimeoffset`\n\nif you need the offset. MySQL: `TIMESTAMP`\n\nconverts to UTC but is bounded to 2038; `DATETIME`\n\ndoesn't convert but has no timezone awareness. Pick one convention and enforce it across the schema.Row width is the quietest performance factor in the whole system. Every engine reads **pages**, not rows. Halving row width doubles rows per page, which roughly halves I/O for scans and doubles effective buffer cache capacity.\n\nPractical consequences:\n\n`TEXT`\n\n/`CLOB`\n\n/`NVARCHAR(MAX)`\n\ncolumn in a hot table. Move it to a side table keyed 1:1. (PostgreSQL's TOAST does this automatically for large values; Oracle stores LOBs out of line by default above a threshold; SQL Server and MySQL are less automatic — MySQL's `DYNAMIC`\n\nrow format stores overflow pages but the 20-byte pointer stays inline.)`SELECT *`\n\nin application code is a design smell for exactly this reason, plus it defeats covering indexes and breaks when columns are added.`NOT NULL`\n\n, `CHECK`\n\n, `UNIQUE`\n\n, and `FOREIGN KEY`\n\ngive the optimizer information. All four engines use them:\n\n`NOT NULL`\n\ndeclaration lets the optimizer skip null-handling logic and enables `NOT IN`\n\noptimizations (see §2).`UNIQUE`\n\nconstraint tells the optimizer a join won't fan out, changing cardinality estimates and enabling join elimination.`LEFT JOIN`\n\nto a parent table and select nothing from it, a validated FK lets SQL Server, Oracle, and PostgreSQL remove the join entirely. This is why views over well-constrained schemas perform better than views over \"we enforce it in the app\" schemas.**Corollary:** disabling constraints for a bulk load and re-enabling them `NOVALIDATE`\n\n(Oracle) or as untrusted (SQL Server) leaves you with constraints the optimizer will not use. Check `sys.foreign_keys.is_not_trusted`\n\nin SQL Server after any bulk load; check `STATUS`\n\n/`VALIDATED`\n\nin Oracle's `USER_CONSTRAINTS`\n\n.\n\n| Engine | Default isolation | Readers block writers? |\n|---|---|---|\n| PostgreSQL | Read Committed (MVCC) | No |\n| Oracle | Read Committed (MVCC, undo-based) | No |\n| MySQL/InnoDB |\nRepeatable Read (MVCC) |\nNo |\n| SQL Server | Read Committed with locking (unless RCSI is enabled) |\nYes |\n\n**This is the biggest operational difference between SQL Server and the other three.** By default, a long-running report in SQL Server takes shared locks and blocks writers. This is why you see `WITH (NOLOCK)`\n\nscattered through legacy SQL Server codebases — it's a bad fix (it permits dirty reads, missed rows, and duplicate rows from page splits during scans). The correct fix is enabling **Read Committed Snapshot Isolation (RCSI)** at the database level, which makes SQL Server behave like the others. Understand the tempdb implications before you flip it on a live system.\n\nMySQL's Repeatable Read default is also worth knowing: it means a long transaction sees a consistent snapshot for its whole duration, and it uses **gap locks** for range operations, which produces deadlocks in patterns that would be fine elsewhere.\n\nOracle and PostgreSQL never block readers, but PostgreSQL's MVCC means **long-running transactions prevent vacuum from cleaning up dead tuples**, causing table bloat and eventually transaction-ID wraparound risk. A leaked idle-in-transaction connection is a PostgreSQL-specific production emergency. Monitor `pg_stat_activity`\n\nfor it.\n\n| Operation | PG | MySQL 8.0 | SQL Server | Oracle |\n|---|---|---|---|---|\n| Add nullable column | Instant | Instant | Instant | Instant |\n| Add column with default | Instant (11+) | Instant (8.0.12+, `INSTANT` algo) |\nInstant (2012+ Enterprise; metadata-only) | Instant (11g+) |\n| Add index | Blocks writes unless `CONCURRENTLY`\n|\nOnline, mostly | Online = Enterprise only | Online = Enterprise |\n| Widen varchar | Instant | Sometimes rebuilds (charset/length byte boundary) | Instant if same byte-length category | Instant |\n| Drop column | Instant (marks it) | Rebuilds table (unless INSTANT) | Instant (marks it) |\n`SET UNUSED` then drop |\n\n**Migration rules for the team:**\n\n`SET lock_timeout`\n\nin PG, `lock_wait_timeout`\n\nin MySQL, `SET LOCK_TIMEOUT`\n\nin SQL Server). A migration that queues behind a long query and then blocks every subsequent query is how a 2-second DDL takes down a site.`ALTER TABLE`\n\ntakes an `ACCESS EXCLUSIVE`\n\nlock. It waits for existing transactions, and everything queues behind it. Even an \"instant\" `ALTER`\n\ncan cause a total outage if a long-running `SELECT`\n\nis in flight. Always `SET lock_timeout = '2s'`\n\nand retry.`pt-online-schema-change`\n\n/ `gh-ost`\n\nexist for the cases where native online DDL doesn't cover you.`DISTINCT`\n\nused to paper over a fan-out`NOT EXISTS`\n\n, never `NOT IN`\n\nagainst a subquery`SELECT`\n\nlist`ORDER BY`\n\nhas a deterministic tiebreaker`ROWS BETWEEN`\n\non any ordered window aggregate (mandatory on SQL Server)`UNION ALL`\n\nunless dedupe is genuinely required| Engine | Command |\n|---|---|\n| PostgreSQL |\n`EXPLAIN (ANALYZE, BUFFERS, VERBOSE)` — `BUFFERS` is the one people skip and shouldn't |\n| MySQL |\n`EXPLAIN ANALYZE` (8.0.18+), `EXPLAIN FORMAT=JSON`\n|\n| SQL Server | Actual execution plan; `SET STATISTICS IO, TIME ON` ; Query Store for history |\n| Oracle |\n`DBMS_XPLAN.DISPLAY_CURSOR(format=>'ALLSTATS LAST')` — the actual plan, not `EXPLAIN PLAN` 's prediction |\n\n**What to look for, universally:** estimated vs. actual row counts diverging by more than ~10× (bad stats or a bad predicate), scans where you expected seeks, and any operator whose row count explodes relative to its input.\n\n`DECIMAL`\n\n; timestamps are timezone-explicitEverything else in this doc is roughly portable. These are the ones to internalize:\n\n`NOT IN`\n\nhandling`'' IS NULL`\n\n, and single-column B-trees don't index NULLs.`ACCESS EXCLUSIVE`\n\nlocks make DDL risky.`RANGE`\n\nis the default everywhere and is almost never what you want.", "url": "https://wpnews.pro/news/sql-design-query-reference", "canonical_source": "https://dev.to/andreimerlescu/sql-design-query-reference-67f", "published_at": "2026-07-27 16:34:27+00:00", "updated_at": "2026-07-27 17:01:19.849110+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models"], "entities": ["Claude", "PostgreSQL", "MySQL", "MariaDB", "MSSQL", "Oracle"], "alternates": {"html": "https://wpnews.pro/news/sql-design-query-reference", "markdown": "https://wpnews.pro/news/sql-design-query-reference.md", "text": "https://wpnews.pro/news/sql-design-query-reference.txt", "jsonld": "https://wpnews.pro/news/sql-design-query-reference.jsonld"}}