Knowledge Guide
HomeDatabasesSQL Fundamentals

SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive)

The syntax of a SELECT is only the entry gate. Underneath it SQL applies a set of semantic rules silently — whether or not you intended them — and it is exactly these rules an interviewer probes to tell “knows the syntax” from “knows how the engine actually resolves ambiguity.” The six gotchas below are not exotic edge cases; they are the six checks a senior engineer's head runs before trusting any non-trivial query: which operator binds first, what an aggregate returns over zero rows, what a stored timestamp actually means, which way a tie rounds, whether two equal-looking strings are actually equal, and what a set operator quietly does to your result. Each is explained by mechanism, then shown breaking a real query.

1. Operator precedence: AND binds tighter than OR

SQL's boolean operators have a fixed precedence — NOT binds tighter than AND, which binds tighter than OR — the exact same shape as arithmetic, where * binds tighter than +. 2 + 3 * 4 is 2 + (3 * 4) = 14, not (2 + 3) * 4 = 20, and nobody writes extra parens to get that. a OR b AND c works the same way: the parser groups it as a OR (b AND c), never (a OR b) AND c — but because English reads left-to-right and "or/and" sound like peers, this one silently surprises people who would never make the arithmetic mistake.

Traced example: the bug

Say the intent is “orders from customer 1 or customer 2, but only if they're in the EU region”:

-- INTENDED: (customer_id = 1 OR customer_id = 2) AND region = 'EU'
SELECT * FROM orders
WHERE customer_id = 1 OR customer_id = 2 AND region = 'EU';

Because AND binds first, this actually parses as customer_id = 1 OR (customer_id = 2 AND region = 'EU'). Trace it row by row:

Rowcustomer_idregionIntended resultActual result
11USexcluded (not EU)included — matches the bare customer_id = 1 branch, region never checked
22USexcluded (not EU)excluded — correctly requires the AND branch
32EUincludedincluded

Row 1 leaks through unconditionally — every order from customer 1 ships regardless of region, because the region = 'EU' check was only ever attached to the customer_id = 2 branch. No error, no warning: the query runs, returns rows, and looks plausible. The fix is to make the intended grouping explicit with parentheses:

SELECT * FROM orders
WHERE (customer_id = 1 OR customer_id = 2) AND region = 'EU';
Two parse trees for WHERE a=1 OR a=2 AND b=3: the naive left-to-right guess groups (a=1 OR a=2) AND b=3, but the actual SQL parse groups a=1 OR (a=2 AND b=3) because AND binds tighter than OR
Two parse trees for WHERE a=1 OR a=2 AND b=3: the naive left-to-right guess groups (a=1 OR a=2) AND b=3, but the actual SQL parse groups a=1 OR (a=2 AND b=3) because AND binds tighter than OR

Rule of thumb: any time AND and OR appear in the same WHERE clause without parentheses, stop and parenthesize explicitly — don't rely on the reader (or yourself, six months later) to recompute precedence correctly.

A related non-sargable trap: leading-wildcard LIKE

A B-tree index is sorted by the column's value, so it can jump straight to a prefix — LIKE 'smith%' can seek to the smith range directly. LIKE '%smith' cannot: the unknown character(s) come first, so no prefix of the sort order narrows the search — the engine must inspect every row's value to test the suffix, degrading to a full scan even with an index present. This is a non-sargable predicate (Search ARGument ABLE — one the engine cannot translate into an index seek); the full selectivity/cost mechanics of what makes a predicate sargable are covered in this guide's Indexing & Storage deep dive on fanout, statistics and sargability — the point here is simply that precedence and sargability are both cases where the query runs but silently does something much more expensive, or different, than the shape of the SQL suggests.

2. Aggregate empty-set semantics: SUM/AVG/MIN/MAX return NULL, COUNT returns 0

An aggregate function folds many input rows down to one output value. COUNT(*) is answering “how many rows did I see?” — and the correct answer for zero rows is the number 0, not “unknown.” SUM, AVG, MIN, and MAX are answering a different question — “what is the total / average / smallest / largest value?” — and over zero rows there is no value to report, so the engine returns NULL (unknown/no-answer), not 0. A second, related rule: every aggregate except COUNT(*) ignores NULL inputs before folding — so AVG(col) divides the sum by the count of non-null rows, not by the total row count.

Traced example: SUM over an empty set breaks downstream arithmetic

-- No orders at all matched this promo code this month
SELECT SUM(amount) AS promo_revenue
FROM orders
WHERE promo_code = 'ZZZ_NONE' AND order_date >= '2026-07-01';
-- result: NULL   (not 0 — zero rows means no value to sum)

-- Now fold that into a report total
SELECT (SELECT SUM(amount) FROM orders
        WHERE promo_code = 'ZZZ_NONE' AND order_date >= '2026-07-01') + 1000 AS total_with_promo;
-- result: NULL   — NULL + 1000 = NULL. The +1000 doesn't survive; the whole expression is destroyed.

NULL propagates through arithmetic: any expression that touches a NULL becomes NULL, so one empty aggregate silently poisons an entire downstream calculation — a total that should have been $1,000 becomes NULL, with no error thrown. The fix is to state explicitly what “no orders” should mean numerically:

SELECT COALESCE(SUM(amount), 0) AS promo_revenue
FROM orders
WHERE promo_code = 'ZZZ_NONE' AND order_date >= '2026-07-01';
-- result: 0

SELECT COALESCE((SELECT SUM(amount) FROM orders
                  WHERE promo_code = 'ZZZ_NONE' AND order_date >= '2026-07-01'), 0) + 1000 AS total_with_promo;
-- result: 1000

The same NULL-skipping rule explains a second surprise: AVG(amount) over rows where some amount values are NULL divides by the count of rows that actually have a value, not by COUNT(*) — three rows with values 10, 20, NULL average to (10+20)/2 = 15, not 30/3 = 10. If you need the NULLs treated as zero for the average, wrap the column: AVG(COALESCE(amount, 0)).

3. Timezone / UTC / DST: wall-clock time vs. an instant

A TIMESTAMP (or DATETIME) column with no time zone stores six plain numbers — year, month, day, hour, minute, second — the reading you'd see on a wall clock. On its own that reading is ambiguous: “2026-11-01 01:30:00” could be any of several real moments depending on which time zone (and, in some zones, which side of a DST transition) it was read in. A TIMESTAMPTZ (or TIMESTAMP WITH TIME ZONE) column instead anchors to a single point on the UTC timeline — an instant. Internally it is normalized to and stored as UTC; whatever local offset you insert with, or ask to display it in, is just a view onto that one instant, applied at input/render time.

Daylight Saving Time transitions are exactly where the difference stops being academic. When clocks spring forward (e.g. 01:59:59 → 03:00:00), the local wall-clock values 02:00–02:59 never happen at all that day — a gap. When clocks fall back (e.g. 01:59:59 → 01:00:00), the local wall-clock values 01:00–01:59 happen twice, an hour apart in real time — an overlap. A bare timestamp value that names a wall-clock reading inside a DST gap describes a moment that literally never occurred; one inside a DST overlap is genuinely ambiguous between two different real instants, one hour apart.

Diagram contrasting a TIMESTAMP without zone value during a DST fall-back, which maps ambiguously to two different UTC instants an hour apart, versus a TIMESTAMPTZ value which maps to exactly one point on the UTC timeline
Diagram contrasting a TIMESTAMP without zone value during a DST fall-back, which maps ambiguously to two different UTC instants an hour apart, versus a TIMESTAMPTZ value which maps to exactly one point on the UTC timeline

The operating rule: store instants in UTC — use timestamptz/TIMESTAMP WITH TIME ZONE for anything that records when something actually happened (an order placed, a log line, an event) — and convert to a local wall-clock representation only at the display/edge layer (the API response, the UI), using the viewer's time zone at render time, not the writer's time zone at write time. Reserve bare timestamp/DATETIME for genuine wall-clock concepts that are deliberately timezone-agnostic, such as “this alarm should ring at 7:00 AM in whatever zone the device is physically in.”

4. Rounding mode & numeric precision

A rounding tie — a value exactly halfway between two representable results, like 2.5 — can be resolved two different ways, and they disagree exactly at the tie: round-half-away-from-zero (the rule taught in school: 2.5 → 3, always round the .5 up in magnitude) and round-half-to-even, a.k.a. “banker's rounding” (2.5 → 2, because 2 is the nearest even neighbor; but 3.5 → 4, because 4 is the nearest even neighbor). IEEE 754 binary floating point defines round-to-nearest-even as its default rounding mode, and it is what Python's built-in round() and Java's RoundingMode.HALF_EVEN implement explicitly — the motivation is statistical, not aesthetic: rounding a large batch of exact .5 ties consistently upward introduces a systematic upward bias in a sum or average, while alternating between round-up and round-down at each tie cancels that bias out over many values. Round-half-away-from-zero has no such self-correcting property, but it's simpler to reason about and is what many decimal/NUMERIC implementations use by default. Engines differ on which one their ROUND() function applies to a fixed-point/decimal column — never assume; test a literal X.5 value against your engine before trusting a rounding-sensitive report, especially one that rounds and then sums many rows.

The FLOAT-for-money surprise

Separately from rounding mode, binary floating point (FLOAT/DOUBLE PRECISION) cannot exactly represent most base-10 fractions, because they aren't exact sums of powers of two — 0.1 in binary is a repeating fraction, so it is stored as the nearest representable double, off by a sliver you'd never notice from one value alone:

SELECT 0.1::float8 + 0.2::float8;   -- 0.30000000000000004, not 0.3
SELECT 0.1::numeric + 0.2::numeric; -- 0.3, exactly

NUMERIC/DECIMAL types store an exact base-10 representation (digits + scale), so 0.1 really is 0.1 with zero representation error. The float error above looks harmless for a single addition, but summing or multiplying thousands of monetary rows lets that same tiny error accumulate into real, visible cents — a report total that's off by $0.03 with no single row obviously wrong. The rule: never store money in FLOAT/DOUBLE; always use DECIMAL/NUMERIC. Reserve FLOAT for genuinely continuous, measurement-style data (sensor readings, scientific quantities) where a relative error is acceptable and the extra range/speed of binary float is worth it.

5. String encoding & collation: byte length, character length, and “equal” strings

Under a multibyte encoding like UTF-8, a single character can occupy more than one byte — ASCII characters are 1 byte, but an accented Latin letter is typically 2 bytes, and CJK characters are typically 3. That's the entire mechanism behind the byte-length-vs-character-length split: 'café' is 4 characters but 5 bytes in UTF-8 (the é alone is 2 bytes); '日本語' (3 Japanese characters) is 3 characters but 9 bytes. Which built-in function reports which number differs by engine — e.g. in MySQL, LENGTH() returns bytes and CHAR_LENGTH() returns characters; in PostgreSQL, length() on text already returns characters, and octet_length() gives you the byte count instead. The names flip, but the underlying distinction — and the fact that it's invisible until multibyte input actually shows up — is universal, and it matters directly for anything measured against a byte-based column limit (e.g. a VARCHAR(255) byte cap in some engines truncating a shorter-looking multibyte string sooner than expected).

Collation is the separate, and more dangerous, half of this: it's the rule set the engine uses to compare, sort, and equate strings — case sensitivity, accent sensitivity, and locale-specific ordering all live here, not in the raw bytes. Two strings can have completely different bytes and still be considered equal under a case-insensitive or accent-insensitive collation. That equality check isn't cosmetic — it's the exact same comparison operator a UNIQUE or PRIMARY KEY constraint uses to decide whether an insert collides with an existing row. A column email UNIQUE defined under a case-insensitive collation will reject inserting 'Bob@x.com' if 'bob@x.com' already exists — the bytes differ, but the collation says they're the same value — while the identical column under a case-sensitive (binary) collation happily accepts both as distinct rows. The same mechanism can break a join in either direction: a.name = b.name can match rows whose raw bytes differ (case/accent folded away by the collation), or fail to match rows that look identical to a human if the two sides carry different, incompatible collations — a common, confusing cause of a join that returns fewer rows than a byte-for-byte reading of the data would predict, or an outright “collation conflict” error.

6. CASE typing, and what UNION quietly costs you

A CASE expression must resolve every branch (including ELSE) to one common, comparable type, but what happens when the branches mix types — say one branch returns a number and another returns text — differs by engine. In MySQL's permissive type system, mixing a numeric and a text CASE branch silently coerces to a common (usually string) type. That can silently turn a column that looks numeric into text, which then sorts lexicographically ('10' < '9') instead of numerically the moment it's used in ORDER BY. In PostgreSQL and other strictly-typed engines, mismatched CASE branch types are a hard error at parse time (“CASE types text and integer cannot be matched”), not a silent cast — so the sorting hazard described here is MySQL-specific, not universal.

UNION, INTERSECT, and EXCEPT have the same default behavior as each other and it costs more than it looks: per the SQL standard, all three imply DISTINCT unless told otherwise. UNION alone must dedup the combined output of both branches — in most engines that means a sort or a hash pass over the entire combined result, on top of whatever it cost to produce each branch. UNION ALL skips that pass entirely: it just concatenates the two branches' rows, keeping duplicates, with no sort/hash overhead. INTERSECT (rows in both) and EXCEPT/MINUS (rows in the first, not the second) are DISTINCT by default the same way; where an engine offers INTERSECT ALL/EXCEPT ALL (PostgreSQL does), those skip the dedup and instead reconcile duplicate counts (multiset semantics — min of the two counts, or a subtracted count) rather than collapsing to a single row.

Pitfalls a working engineer actually hits

Judgment layer: how a senior engineer decides

Takeaways

Related pages


Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive)** (Databases) and want to truly understand it. Explain SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **SQL Fundamentals — The Semantic Gotchas: Precedence, NULL/Empty-Set, Timezone, Rounding & Collation (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes