Section 4 · SQL in the Real World
Readable SQL and Dialect Differences
15 min3 min readSQL exercise
Lesson at a glance
Format queries so a reviewer can check them quickly, and translate the SQLite habits from this course to PostgreSQL by knowing where dates, booleans, LIKE and grouping rules differ.
No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.
Yara, the senior analyst on your team, reviews every query before it reaches a stakeholder. Her first comment on new analysts’ work is rarely about the logic. It’s “I can’t check this quickly.” Her second, this quarter, is practical: Cartwheel is moving its reporting to a PostgreSQL warehouse, and every query you’ve written needs to survive the move. This lesson covers both: SQL that reviewers can read, and SQL that travels.
A query someone else has to check
Here’s a real query from the support team’s old dashboard. It runs and returns plausible numbers:
select t.channel,count(*),avg(satisfaction) from support_tickets t,customers c where t.customer_id=c.customer_id and closed_at is not null and opened_at>='2025-01-01' group by 1 order by 3 desc;A reviewer has to work to answer basic questions. Why is customers here? (It isn’t used; it’s leftover from an earlier version, and it’s a join someone must now reason about.) Is that a join condition or a filter? Which table does satisfaction belong to? What is column 3? Is the 2025 filter missing an upper bound or is that deliberate? Here’s the same analysis, written to be checked:
-- Support satisfaction by contact channel, tickets opened in 2025.
-- grain: one row per channel. Only closed tickets: open ones can't be rated yet.
SELECT
t.channel,
COUNT(*) AS closed_tickets,
COUNT(t.satisfaction) AS rated,
ROUND(AVG(t.satisfaction), 2) AS avg_satisfaction
FROM support_tickets AS t
WHERE t.closed_at IS NOT NULL
AND t.opened_at >= '2025-01-01'
AND t.opened_at < '2026-01-01'
GROUP BY t.channel
ORDER BY avg_satisfaction DESC;The changes, in order of importance:
- Remove what isn’t used. The unused join is gone. Every table in FROM is a potential fan-out or a filter in disguise, so it must earn its place.
- Explicit joins. When you do join, write
JOIN ... ON, never a comma plus a WHERE condition. The join condition then sits next to the table it belongs to. - Names over positions. Every output column has a name, and ORDER BY uses it.
GROUP BY 1is a contested convenience; names survive edits to the SELECT list. - One column per line, qualified with a role-based alias. Diffs in code review become one line per change.
- Comments that say why. The header states the question and the grain; “only closed tickets” explains a decision. Don’t comment what the SQL plainly says.
- Show the denominator.
ratedsits next to the average, the response-rate lesson from Section 1.
Uppercase keywords, leading or trailing commas, and indentation width are team conventions. Pick one, apply it everywhere, and let a formatter enforce it. Consistency matters far more than which style wins.
From SQLite to PostgreSQL
SQL is a standard, but every engine has its own functions and its own leniency. Almost everything in this course runs unchanged in PostgreSQL: joins, CTEs, recursive CTEs, window functions, FILTER, NULLIF, COALESCE, IS DISTINCT FROM. The differences cluster in a few places:
| Task | SQLite | PostgreSQL |
|---|---|---|
| Month label | strftime('%Y-%m', d) |
to_char(d, 'YYYY-MM') |
| Start of month | date(d, 'start of month') |
date_trunc('month', d) |
| Add an interval | date(d, '+7 days') |
d + INTERVAL '7 days' |
| Hours between | (julianday(b) - julianday(a)) * 24 |
EXTRACT(EPOCH FROM b - a) / 3600 |
| Case-insensitive match | LIKE (ASCII) |
ILIKE |
| Two-way choice | IIF(c, a, b) or CASE |
CASE |
| Count a condition | SUM(cond) or FILTER |
COUNT(*) FILTER (WHERE cond) |
The biggest change is underneath the table. PostgreSQL has real date and timestamp types, so date columns are compared as dates, and date arithmetic returns intervals rather than numbers. Booleans are a real type too: SUM(status = 'cancelled') fails there because you can’t sum a boolean, which is why FILTER is the portable choice.
Two smaller differences catch people regularly. Integer division truncates in both engines (7 / 2 is 3), so keep writing 100.0 * for percentages. And PostgreSQL sorts NULLs last in ascending order where SQLite sorts them first, so add NULLS FIRST or NULLS LAST whenever NULL placement matters.
The exercise is a real porting job: a warehouse query that fails in SQLite. After that, the capstone puts the whole course together.
Key takeaways
- Readable SQL uses explicit JOIN … ON, one column per line, role-based aliases, named CTE steps and comments that explain why, not what.
- Remove what the query doesn’t use, such as unused joins and SELECT *, because every extra table is a possible fan-out and a question for the reviewer.
- [object Object]
- SQLite is more lenient than PostgreSQL (bare columns, case-insensitive LIKE, booleans as 0/1), so a query that runs in SQLite may fail or behave differently in PostgreSQL.