EduFlow
SQL for Analysts
0%

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.

  1. 1A query someone else has to check
  2. 2From SQLite to PostgreSQL

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:

SQL
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:

SQL
-- 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:

  1. 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.
  2. 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.
  3. Names over positions. Every output column has a name, and ORDER BY uses it. GROUP BY 1 is a contested convenience; names survive edits to the SELECT list.
  4. One column per line, qualified with a role-based alias. Diffs in code review become one line per change.
  5. 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.
  6. Show the denominator. rated sits 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.

Further reading

Check your understanding

4 questions

Question 1 of 4

FROM support_tickets t, customers c WHERE t.customer_id = c.customer_id appears in a query that never uses any c. column. What should the reviewer ask for?

Question 2 of 4

Which SQLite expression has a direct PostgreSQL equivalent in date_trunc('month', order_date)?

Question 3 of 4

This runs in SQLite: SELECT channel, status, COUNT(*) FROM orders GROUP BY channel. What happens in PostgreSQL?

Question 4 of 4

WHERE email LIKE '%@EXAMPLE.COM' finds all 600 customers in SQLite. How many does it find in PostgreSQL, where emails are stored in lower case?

Practice

SQL exercise

Port a warehouse query to SQLite

Huda found this query in the PostgreSQL warehouse and wants the same numbers from Cartwheel’s SQLite database: for customers in Dubai, one row per month of 2025 with month (‘YYYY-MM’), orders (non-cancelled orders) and app_orders (those placed through the mobile app). Sort by month. Order matters.

The starter fails in SQLite. Translate every PostgreSQL-only piece into SQLite and keep the logic the same.

Tables
customers
(customer_id, first_name, last_name, email, country, city, signup_date, segment, referred_by)
orders
(order_id, customer_id, order_date, status, channel, coupon_code, shipping_fee)
order_items
(order_item_id, order_id, product_id, quantity, unit_price, discount)
products
(product_id, name, category_id, unit_price, unit_cost, launched_on, is_active)
categories
(category_id, name, parent_id)
returns
(return_id, order_item_id, return_date, reason, refund_amount)
employees
(employee_id, first_name, last_name, title, department, manager_id, hire_date, salary)
support_tickets
(ticket_id, customer_id, order_id, opened_at, closed_at, priority, channel, satisfaction, agent_id)
web_sessions
(session_id, customer_id, started_at, source, device, pages_viewed, added_to_cart, purchased)
-- Pasted from the PostgreSQL warehouse. It fails in SQLite: port it.
-- grain: one row per month, Dubai customers, 2025, non-cancelled orders
SELECT
  to_char(date_trunc('month', o.order_date::timestamp), 'YYYY-MM') AS month,
  COUNT(*) AS orders,
  COUNT(*) FILTER (WHERE o.channel ILIKE 'MOBILE%') AS app_orders
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
WHERE c.city ILIKE 'dubai'
  AND o.status <> 'cancelled'
  AND o.order_date >= DATE '2025-01-01'
  AND o.order_date < DATE '2026-01-01'
GROUP BY month
ORDER BY month;Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.