{"id":"l-sql-4-3","courseId":"sql-for-analysts","lang":"en","kind":"lesson","summary":"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.","html":"<p>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.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">A query someone else has to check</h2>\n<p>Here’s a real query from the support team’s old dashboard. It runs and returns plausible numbers:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"0\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">select</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">count</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">),</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">avg</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(satisfaction) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> support_tickets t,customers c </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">where</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">customer_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">c</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">customer_id</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> and</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> closed_at </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">is not null</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> and</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> opened_at</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">>=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">'2025-01-01'</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> group by</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 1</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> order by</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> 3</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> desc</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"0\" hidden></div></div>\n<p>A reviewer has to work to answer basic questions. Why is <code dir=\"ltr\">customers</code> 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 <code dir=\"ltr\">satisfaction</code> 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:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"sql\" data-run=\"sql\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">SQL</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn code-run\" data-action=\"run\" data-lang=\"sql\" data-index=\"1\" aria-label=\"Run this example\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z\"/></svg><span>Run</span></button><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">-- Support satisfaction by contact channel, tickets opened in 2025.</span></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">-- grain: one row per channel. Only closed tickets: open ones can't be rated yet.</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">SELECT</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">*</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)                      </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> closed_tickets,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  COUNT</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">satisfaction</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)         </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> rated,</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  ROUND</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">AVG</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">satisfaction</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">), </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">2</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> avg_satisfaction</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">FROM</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> support_tickets </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">AS</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> t</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">WHERE</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">closed_at</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> IS NOT NULL</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">opened_at</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> >=</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '2025-01-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  AND</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">opened_at</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> &#x3C;</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  '2026-01-01'</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">GROUP BY</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> t</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">channel</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">ORDER BY</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> avg_satisfaction </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">DESC</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span></code></pre><div class=\"run-output\" data-run-output=\"1\" hidden></div></div>\n<p>The changes, in order of importance:</p>\n<ol>\n<li><strong>Remove what isn’t used.</strong> 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.</li>\n<li><strong>Explicit joins.</strong> When you do join, write <code dir=\"ltr\">JOIN ... ON</code>, never a comma plus a WHERE condition. The join condition then sits next to the table it belongs to.</li>\n<li><strong>Names over positions.</strong> Every output column has a name, and ORDER BY uses it. <code dir=\"ltr\">GROUP BY 1</code> is a contested convenience; names survive edits to the SELECT list.</li>\n<li><strong>One column per line, qualified with a role-based alias.</strong> Diffs in code review become one line per change.</li>\n<li><strong>Comments that say why.</strong> The header states the question and the grain; “only closed tickets” explains a decision. Don’t comment <em>what</em> the SQL plainly says.</li>\n<li><strong>Show the denominator.</strong> <code dir=\"ltr\">rated</code> sits next to the average, the response-rate lesson from Section 1.</li>\n</ol>\n<p>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.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Build long queries as CTE steps</span></p>\n<p>For anything longer than one screen, write one CTE per logical step, put a <code dir=\"ltr\">-- grain:</code> comment on each, and keep the final SELECT short. A reviewer can then run each step on its own, exactly as you did while writing it.</p>\n</aside>\n<h2 id=\"sec-2\" tabindex=\"-1\">From SQLite to PostgreSQL</h2>\n<p>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, <code dir=\"ltr\">FILTER</code>, <code dir=\"ltr\">NULLIF</code>, <code dir=\"ltr\">COALESCE</code>, <code dir=\"ltr\">IS DISTINCT FROM</code>. The differences cluster in a few places:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>Task</th>\n<th>SQLite</th>\n<th>PostgreSQL</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Month label</td>\n<td><code dir=\"ltr\">strftime('%Y-%m', d)</code></td>\n<td><code dir=\"ltr\">to_char(d, 'YYYY-MM')</code></td>\n</tr>\n<tr>\n<td>Start of month</td>\n<td><code dir=\"ltr\">date(d, 'start of month')</code></td>\n<td><code dir=\"ltr\">date_trunc('month', d)</code></td>\n</tr>\n<tr>\n<td>Add an interval</td>\n<td><code dir=\"ltr\">date(d, '+7 days')</code></td>\n<td><code dir=\"ltr\">d + INTERVAL '7 days'</code></td>\n</tr>\n<tr>\n<td>Hours between</td>\n<td><code dir=\"ltr\">(julianday(b) - julianday(a)) * 24</code></td>\n<td><code dir=\"ltr\">EXTRACT(EPOCH FROM b - a) / 3600</code></td>\n</tr>\n<tr>\n<td>Case-insensitive match</td>\n<td><code dir=\"ltr\">LIKE</code> (ASCII)</td>\n<td><code dir=\"ltr\">ILIKE</code></td>\n</tr>\n<tr>\n<td>Two-way choice</td>\n<td><code dir=\"ltr\">IIF(c, a, b)</code> or <code dir=\"ltr\">CASE</code></td>\n<td><code dir=\"ltr\">CASE</code></td>\n</tr>\n<tr>\n<td>Count a condition</td>\n<td><code dir=\"ltr\">SUM(cond)</code> or <code dir=\"ltr\">FILTER</code></td>\n<td><code dir=\"ltr\">COUNT(*) FILTER (WHERE cond)</code></td>\n</tr>\n</tbody>\n</table></div><p>The biggest change is underneath the table. PostgreSQL has real <code dir=\"ltr\">date</code> and <code dir=\"ltr\">timestamp</code> types, so date columns are compared as dates, and date arithmetic returns intervals rather than numbers. Booleans are a real type too: <code dir=\"ltr\">SUM(status = 'cancelled')</code> fails there because you can’t sum a boolean, which is why FILTER is the portable choice.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Trusting SQLite’s leniency</span></p>\n<p>SQLite accepts bare columns in GROUP BY queries, matches <code dir=\"ltr\">LIKE</code> without regard to case, and lets you compare text with numbers. PostgreSQL rejects or behaves differently in each case. That’s mostly good news, because the strict engine turns silent bugs into errors. But it means “it ran in SQLite” doesn’t mean “it’s correct”. If a query relies on a lenient behaviour, rewrite it explicitly: add the column to GROUP BY, use <code dir=\"ltr\">LOWER()</code> on both sides, cast types on purpose.</p>\n</aside>\n<p>Two smaller differences catch people regularly. Integer division truncates in both engines (<code dir=\"ltr\">7 / 2</code> is 3), so keep writing <code dir=\"ltr\">100.0 *</code> for percentages. And PostgreSQL sorts NULLs last in ascending order where SQLite sorts them first, so add <code dir=\"ltr\">NULLS FIRST</code> or <code dir=\"ltr\">NULLS LAST</code> whenever NULL placement matters.</p>\n<p>The exercise is a real porting job: a warehouse query that fails in SQLite. After that, the capstone puts the whole course together.</p>\n","toc":[{"id":"sec-1","text":"A query someone else has to check","level":2},{"id":"sec-2","text":"From SQLite to PostgreSQL","level":2}],"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":[{"title":"SQLite quirks, caveats and gotchas","url":"https://www.sqlite.org/quirks.html"},{"title":"PostgreSQL date/time functions and operators","url":"https://www.postgresql.org/docs/current/functions-datetime.html"},{"title":"PostgreSQL pattern matching (LIKE and ILIKE)","url":"https://www.postgresql.org/docs/current/functions-matching.html"}],"quiz":[{"q":"<p><code dir=\"ltr\">FROM support_tickets t, customers c WHERE t.customer_id = c.customer_id</code> appears in a query that never uses any <code dir=\"ltr\">c.</code> column. What should the reviewer ask for?</p>","options":[{"text":"Rewrite it as a LEFT JOIN so no tickets are lost.","why":"Changing the join type keeps the unused table; the issue is that it’s there at all."},{"text":"Remove the customers table; it adds nothing but a possible source of fan-out or lost rows.","why":"Correct. Every table in FROM is something the reader must reason about. If no column is used and it isn’t filtering on purpose, delete it."},{"text":"Add DISTINCT to the SELECT to be safe.","why":"DISTINCT papers over duplication instead of removing its cause, and here it could also merge real rows."},{"text":"Nothing; comma joins are standard SQL and fine to keep.","why":"Comma joins are legal, but they hide the join condition in WHERE, where it’s easy to forget. Explicit JOIN … ON is the readable form."}],"answer":1},{"q":"<p>Which SQLite expression has a direct PostgreSQL equivalent in <code dir=\"ltr\">date_trunc('month', order_date)</code>?</p>","options":[{"text":"<code dir=\"ltr\">julianday(order_date)</code>","why":"julianday converts to a day number; it doesn’t truncate to the month."},{"text":"<code dir=\"ltr\">strftime('%w', order_date)</code>","why":"That’s the weekday number, not the start of the month."},{"text":"<code dir=\"ltr\">date(order_date, '+1 month')</code>","why":"That adds a month; the PostgreSQL equivalent is <code dir=\"ltr\">order_date + INTERVAL '1 month'</code>."},{"text":"<code dir=\"ltr\">date(order_date, 'start of month')</code>","why":"Correct. Both return the first day of the order’s month. PostgreSQL returns a timestamp, SQLite returns ‘YYYY-MM-01’ text."}],"answer":3},{"q":"<p>This runs in SQLite: <code dir=\"ltr\">SELECT channel, status, COUNT(*) FROM orders GROUP BY channel</code>. What happens in PostgreSQL?</p>","options":[{"text":"It fails, because <code dir=\"ltr\">status</code> is neither grouped nor aggregated.","why":"Correct. PostgreSQL enforces the GROUP BY rule that SQLite relaxes, so the bare-column bug becomes an error you can’t miss."},{"text":"It returns the same result as SQLite.","why":"PostgreSQL refuses to pick an arbitrary value for a bare column."},{"text":"It returns one row per channel and status.","why":"That would need <code dir=\"ltr\">GROUP BY channel, status</code>; PostgreSQL doesn’t add grouping columns for you."}],"answer":0},{"q":"<p><code dir=\"ltr\">WHERE email LIKE '%@EXAMPLE.COM'</code> finds all 600 customers in SQLite. How many does it find in PostgreSQL, where emails are stored in lower case?</p>","options":[{"text":"600, LIKE behaves the same everywhere","why":"LIKE’s case sensitivity is one of the most common dialect differences."},{"text":"An error, because LIKE needs ILIKE in PostgreSQL","why":"PostgreSQL has LIKE too; it’s simply case-sensitive."},{"text":"0, because PostgreSQL’s LIKE is case-sensitive; use ILIKE or compare LOWER(email)","why":"Correct. SQLite’s LIKE ignores ASCII case by default, PostgreSQL’s doesn’t. ILIKE is PostgreSQL’s case-insensitive version."}],"answer":2}],"exercise":{"kind":"sql","starter":"-- Pasted from the PostgreSQL warehouse. It fails in SQLite: port it.\n-- grain: one row per month, Dubai customers, 2025, non-cancelled orders\nSELECT\n  to_char(date_trunc('month', o.order_date::timestamp), 'YYYY-MM') AS month,\n  COUNT(*) AS orders,\n  COUNT(*) FILTER (WHERE o.channel ILIKE 'MOBILE%') AS app_orders\nFROM orders AS o\nJOIN customers AS c ON c.customer_id = o.customer_id\nWHERE c.city ILIKE 'dubai'\n  AND o.status <> 'cancelled'\n  AND o.order_date >= DATE '2025-01-01'\n  AND o.order_date < DATE '2026-01-01'\nGROUP BY month\nORDER BY month;","solution":"SELECT\n  strftime('%Y-%m', o.order_date) AS month,\n  COUNT(*) AS orders,\n  COUNT(*) FILTER (WHERE o.channel LIKE 'MOBILE%') AS app_orders\nFROM orders AS o\nJOIN customers AS c ON c.customer_id = o.customer_id\nWHERE c.city LIKE 'dubai'\n  AND o.status <> 'cancelled'\n  AND o.order_date >= '2025-01-01'\n  AND o.order_date < '2026-01-01'\nGROUP BY month\nORDER BY month;","orderMatters":true,"title":"Port a warehouse query to SQLite","prompt":"<p>Huda found this query in the PostgreSQL warehouse and wants the same numbers from Cartwheel’s SQLite database: for customers in <strong>Dubai</strong>, one row per month of <strong>2025</strong> with <code dir=\"ltr\">month</code> (‘YYYY-MM’), <code dir=\"ltr\">orders</code> (non-cancelled orders) and <code dir=\"ltr\">app_orders</code> (those placed through the mobile app). Sort by <code dir=\"ltr\">month</code>. Order matters.</p>\n<p>The starter fails in SQLite. Translate every PostgreSQL-only piece into SQLite and keep the logic the same.</p>\n","hints":["Run it and read the first error. Then look for each PostgreSQL feature: <code dir=\"ltr\">::</code> casts, <code dir=\"ltr\">to_char</code>, <code dir=\"ltr\">date_trunc</code>, <code dir=\"ltr\">ILIKE</code> and <code dir=\"ltr\">DATE '...'</code> literals.","A ‘YYYY-MM’ month label in SQLite is <code dir=\"ltr\">strftime('%Y-%m', o.order_date)</code>. Dates are ISO text, so the date literals become plain strings.","SQLite’s LIKE is already case-insensitive for ASCII, so <code dir=\"ltr\">ILIKE</code> becomes <code dir=\"ltr\">LIKE</code>. <code dir=\"ltr\">COUNT(*) FILTER (WHERE ...)</code> works in both engines and can stay."],"explanation":"<p>Four things are PostgreSQL-only: the <code dir=\"ltr\">::timestamp</code> cast, <code dir=\"ltr\">to_char</code> with <code dir=\"ltr\">date_trunc</code>, <code dir=\"ltr\">ILIKE</code>, and typed <code dir=\"ltr\">DATE '...'</code> literals. In SQLite the month label is <code dir=\"ltr\">strftime('%Y-%m', ...)</code>, ISO text needs no cast, and LIKE already ignores ASCII case. <code dir=\"ltr\">FILTER</code> is standard SQL and works unchanged. Notice the reverse trap if this query ever goes back: SQLite’s <code dir=\"ltr\">LIKE 'MOBILE%'</code> matches ‘mobile_app’, but in PostgreSQL that LIKE would match nothing, which is why the original used ILIKE. Dubai’s busiest month was December, with 23 orders.</p>\n","checkLabels":{}},"words":710,"readMinutes":3}