1. Courses
  2. System Design
  3. SQL & Data Modeling
  4. Stop 2

40 minBeginner

SQL you'll actually use

An ORM covers the daily create, read and update work. The questions a business asks, such as "top sellers per store" or "which stores dropped this week", need real SQL. This lesson covers the SQL patterns behind almost every shop report, run against the tables from Modeling data in tables.

By the end you can

  • Pick INNER or LEFT JOIN on purpose, and spot the WHERE clause that quietly turns one into the other
  • Group with HAVING and FILTER, answer "is there any?" with EXISTS, and handle NULL without surprises
  • Rank rows within each store and compare a day with the day before, using window functions
  • Write an upsert that is safe when a request is retried
  • Run any of these from Prisma with $queryRaw, typed and safe from SQL injection

The everyday picture

Imagine you want to know how the Riverside store did last week. You could ask the store to send every receipt to head office, then read 2,000 slips yourself and add them up. Or you could phone the store manager and ask one precise question: "Total sales per day last week, and your three best sellers." The manager has the books right there. You get back seven numbers and three names.

SQL is that precise question. The database already holds every receipt and knows how to scan, sort and add them quickly. When your NestJS code loads 50,000 rows into memory to sum them, it is reading every slip by hand. When it sends one good query, it gets back only the answer, and that matters most on a slow store connection or a busy server.

The shape of a query, and the order it really runs in

You write a query in one order, and Postgres works through it in a different, logical order. Knowing the second order explains most beginner errors.

SQL
SELECT   sku, name, unit_price_cents    -- 5. pick columns
FROM     article                        -- 1. start from a table (and its JOINs)
WHERE    category_id = 2                -- 2. keep matching rows
  AND    unit_price_cents BETWEEN 100 AND 300
ORDER BY unit_price_cents DESC          -- 6. sort what is left
LIMIT    3;                             -- 7. keep the first 3
-- (3. GROUP BY and 4. HAVING come between WHERE and SELECT when present)

Two practical results. WHERE runs before SELECT, so you cannot use a column alias from SELECT inside WHERE. And ORDER BY runs after SELECT, so there you can. Without ORDER BY, the row order is not guaranteed, even when it looks stable in testing. Any query with LIMIT, or any screen that shows a list, needs an explicit ORDER BY.

Quick check

SELECT total_cents / 100 AS dollars FROM sale WHERE dollars > 50 fails with "column dollars does not exist". Why?

Joins: which rows survive

A join pairs rows from two tables using a condition, usually "this foreign key equals that id". The type of join decides what happens to rows that find no partner.

  • keeps only matched pairs. A store with no sales today disappears from the result.
  • keeps every row of the left table. Where there is no match, the right side's columns are NULL.
site sale (today) 1 Riverside 2 Hill Road 3 Old Town R-11 site 1 R-12 site 1 R-13 site 2 site 3 has no sale today INNER JOIN 1 Riverside R-11 1 Riverside R-12 2 Hill Road R-13 Old Town is gone 3 rows LEFT JOIN 1 Riverside R-11 1 Riverside R-12 2 Hill Road R-13 3 Old Town NULL 4 rows count(s.id) gives 0
Each arrow is a match on sale.site_id = site.id. INNER JOIN returns only matched pairs, so the store with no sales vanishes. LEFT JOIN keeps every store and fills the missing sale with NULL, which is what a "stores with zero sales" report needs.

Here is the report that needs a LEFT JOIN: receipts per site today, including sites with none. That second group is often the most important, because a silent store may have a broken POS.

SQL
SELECT si.site_code, count(s.id) AS receipts_today   -- count(s.id) skips NULLs, so 0
FROM site si
LEFT JOIN sale s
       ON s.site_id = si.id
      AND s.sale_date = current_date                  -- filter the right side HERE
GROUP BY si.site_code
ORDER BY receipts_today;
Output
 site_code | receipts_today
-----------+----------------
 DC01      |              0     <- a DC has no tills: expected
 S004      |            128     <- a store at less than half its usual 300: look at it
 P001      |            300
 ...

Grouping, HAVING, and NULL traps

GROUP BY folds many rows into one row per group, and aggregates (count, sum, avg, min, max) summarise each group. WHERE filters rows before grouping; HAVING filters groups after. "Days where a store issued fewer than 200 receipts" is a filter on a count, so it must be HAVING:

SQL
SELECT site_id, sale_date, count(*) AS receipts
FROM sale
WHERE sale_date >= current_date - 7          -- rows first
GROUP BY site_id, sale_date
HAVING count(*) < 200                        -- then groups
ORDER BY sale_date, site_id;

means "unknown". Any comparison with an unknown value gives unknown, and WHERE keeps only rows where the test is true. That produces three classic surprises:

SQL
SELECT NULL = NULL;                       -- NULL, not true. Use IS NULL instead
SELECT count(*), count(discount)          -- count(col) skips NULLs
FROM (VALUES (10), (NULL), (5)) AS v(discount);              --  3 | 2
SELECT 1 WHERE 2 NOT IN (1, NULL);        -- no row: "is 2 equal to unknown?" is unknown
SELECT coalesce(NULL, 0);                 -- 0: first non-NULL value
SELECT NULL IS DISTINCT FROM 1;           -- true: a NULL-safe "not equal"

The NOT IN case bites in real reports: "products not in this promotion list" returns nothing if the list has one NULL. Prefer NOT EXISTS for such checks. Use coalesce(x, 0) when a missing sum should display as zero.

Quick check

What does SELECT avg(x) FROM (VALUES (10), (NULL), (20)) AS v(x) return?

Questions about "is there any?": EXISTS, DISTINCT ON and FILTER

Many business questions are yes-or-no checks about other rows. "Which products are in stock at Station Road but have not sold in a week?" is a question about the absence of sale lines. A with EXISTS or NOT EXISTS answers it directly. The database stops looking as soon as it finds one matching row, and NULLs cannot spoil the answer the way they spoil NOT IN.

SQL
-- Dead stock: on the shelf at S004, no sale in the last 7 days
SELECT a.sku, a.name, st.qty
FROM stock st
JOIN article a ON a.id = st.article_id
WHERE st.site_id = (SELECT id FROM site WHERE site_code = 'S004')   -- one value
  AND st.qty > 0
  AND NOT EXISTS (                                  -- "there is no sale line..."
        SELECT 1
        FROM sale s
        JOIN sale_line sl ON sl.sale_id = s.id
        WHERE s.site_id = st.site_id                -- ...for this store
          AND sl.article_id = st.article_id         -- ...and this product
          AND s.sale_date >= current_date - 7
      )
ORDER BY st.qty DESC;

The inner query refers to st from the outer query, so it runs once per stock row in principle. Postgres usually turns it into an efficient anti-join, which you can confirm with EXPLAIN (Reading query plans and fixing slow queries). On the practice data below, every product sells every day, so the result is empty until you add a product that has stock but no sales. That is a good habit in itself: create a row you expect the query to find, and check that it does.

Two more tools save a lot of code:

  • DISTINCT ON (col) keeps the first row of each group, in the order given by ORDER BY. "The latest receipt from each store" becomes SELECT DISTINCT ON (site_id) … ORDER BY site_id, sold_at DESC. It is a Postgres extension to SQL, and the ORDER BY must start with the same columns.
  • count(*) FILTER (WHERE …) counts only the rows that match, inside a normal GROUP BY. One pass over the data gives you several columns: all receipts, wallet receipts, and the share paid without cash.
SQL
SELECT si.site_code,
       count(*)                                         AS receipts,
       count(*) FILTER (WHERE s.pay_method = 'WALLET')  AS wallet,
       round(100.0 * count(*) FILTER (WHERE s.pay_method IN ('CARD', 'WALLET'))
             / count(*), 1)                             AS cashless_pct
FROM sale s
JOIN site si ON si.id = s.site_id
WHERE s.sale_date >= current_date - 6
GROUP BY si.site_code
ORDER BY cashless_pct DESC;
Output
 site_code | receipts | wallet | cashless_pct
-----------+----------+--------+--------------
 P001      |     2100 |    720 |         68.0
 S005      |     2100 |    716 |         67.2
 S002      |     2100 |    723 |         66.9
 ...

The same report written with one query per payment method would scan the sales three times and need code to stitch the results together. Note the 100.0: it makes the maths decimal. With only integers, Postgres drops the fraction.

CTEs and window functions: the report toolkit

A (WITH name AS (…)) gives a name to one step of a query. It turns a deep nest of brackets into a readable recipe: first compute this week, then last week, then compare. In Postgres 12 and later the planner can merge a simple CTE into the main query, so naming a step usually costs nothing.

A calculates across a set of rows but, unlike GROUP BY, keeps every row. You describe the window with OVER (PARTITION BY … ORDER BY …): partition splits the rows into groups, and order sorts within each group. The ones you will use most:

  • row_number(): 1, 2, 3 within each partition. The answer to "top N per group".
  • sum(x) OVER (ORDER BY day): a running total, such as month-to-date sales.
  • lag(x): the value from the previous row, such as yesterday's sales next to today's.
site article units row_number partition S001 S001 Rice 5kg 1928 1 S001 Cooking oil 799 2 S001 Lentils 1kg 655 3 partition S002 S002 Rice 5kg 1798 1 S002 Sugar 1kg 839 2 S002 Cooking oil 702 3 PARTITION BY site_id ORDER BY units DESC
PARTITION BY site_id splits the rows into one group per store, ORDER BY units DESC sorts inside each group, and row_number() restarts at 1 for every store. Keep WHERE rn <= 5 and you have the top five per store. Every row stays; nothing is folded together.
Think first: why not write ORDER BY units DESC LIMIT 5 instead?

That gives the top five rows of the whole result, which would likely be five rows from the busiest store. You need five per store. LIMIT applies once to the whole query; a window function with PARTITION BY ranks inside each group. row_number() also breaks ties arbitrarily. If two products tie and you want both to share rank 3, use rank() or dense_rank(). Add a tie-breaker column (ORDER BY units DESC, article_id) when you need the same answer every run.

For time, bucket with date_trunc. It cuts a timestamp down to the hour, day, week or month. Convert to the store's time zone first; otherwise the buckets follow UTC, and "9 pm" lands in the 3 pm bucket.

SQL
SELECT date_trunc('hour', sold_at AT TIME ZONE 'Asia/Dhaka') AS hour_local,
       count(*) AS receipts
FROM sale
WHERE site_id = 1 AND sale_date = current_date - 1
GROUP BY 1
ORDER BY 1;

Quick check

sum(total_cents) / lag(sum(total_cents)) OVER (ORDER BY sale_date) returns whole numbers such as 1 or 0, never 1.08. Why?

Upsert: insert or update in one safe step

An answers "add this row, or update it if it already exists". The naive version is two queries: SELECT, then INSERT or UPDATE. Two handheld scanners posting the same delivery at the same time can both see "not there" and both insert. INSERT … ON CONFLICT does the check and the write as one atomic step, using a unique constraint as the referee.

  • ON CONFLICT (cols) DO UPDATE SET … updates the existing row. Inside it, EXCLUDED means "the row you tried to insert".
  • ON CONFLICT (cols) DO NOTHING skips silently. That makes a retried upload harmless, as with the UNIQUE (site_id, local_txn_id) rule from Modeling data in tables.

The conflict target must match a unique index or constraint exactly. Transactions, ACID, isolation and locks covers what else can go wrong when two writers touch the same row.

Build it

We will load a month of practice data, then write four real reports and call one from NestJS through Prisma. To try the core patterns without installing anything, use the exercises in the Practice section, which run in your browser.

  1. Load practice data

    Run this on the tables from Modeling data in tables. It creates 8 sites, 40 products and 30 days of receipts. Low article ids sell more, so rankings look realistic. One store loses receipts in the last 3 days so the comparison report has something to find. Prices are in cents.

    SQL
    INSERT INTO site (site_code, name, format, city) VALUES
      ('S001', 'Riverside', 'STORE', 'Bayside'),     ('S002', 'Hill Road', 'STORE', 'Bayside'),
      ('S003', 'Market Square', 'STORE', 'Bayside'), ('S004', 'Station Road', 'STORE', 'Bayside'),
      ('S005', 'Old Town', 'STORE', 'Hilltown'),     ('S006', 'Lakeview', 'STORE', 'Riverton'),
      ('P001', 'Ravi''s Corner Shop', 'PARTNER', 'Bayside'), ('DC01', 'North DC', 'DC', 'Bayside');
    INSERT INTO category (name) VALUES ('Grocery'), ('Fresh'), ('Personal care');
    INSERT INTO article (sku, name, category_id, unit_price_cents)
    SELECT 'SKU' || lpad(g::text, 5, '0'), 'Article ' || g, 1 + g % 3,
           50 + floor(random() * 1950)::int                -- 50 to 1999 cents
    FROM generate_series(1, 40) AS g;
    
    -- 300 receipts per day per selling site, 30 days
    INSERT INTO sale (site_id, local_txn_id, sale_date, sold_at, total_cents, pay_method)
    SELECT s.id, s.site_code || '-' || to_char(current_date - d, 'YYMMDD') || '-' || n,
           current_date - d,
           (current_date - d + time '09:00' + random() * interval '13 hours') AT TIME ZONE 'Asia/Dhaka',
           0, (ARRAY['CASH','CARD','WALLET'])[1 + floor(random() * 3)::int]::pay_method
    FROM site s, generate_series(0, 29) AS d, generate_series(1, 300) AS n
    WHERE s.format <> 'DC';
    SQL
    -- 1 to 4 lines per receipt; power(random(), 2) favours low article ids
    INSERT INTO sale_line (sale_id, line_no, article_id, qty, unit_price_cents)
    SELECT s.id, l.n, a.id, l.qty, a.unit_price_cents
    FROM sale s
    CROSS JOIN LATERAL (
      SELECT n, 1 + floor(power(random(), 2) * 40)::int AS article_id,
                1 + floor(random() * 3)::int AS qty
      FROM generate_series(1, 1 + (s.id % 4)::int) AS n
    ) AS l
    JOIN article a ON a.id = l.article_id;
    
    UPDATE sale s SET total_cents = t.amount
    FROM (SELECT sale_id, sum(qty * unit_price_cents) AS amount FROM sale_line GROUP BY sale_id) AS t
    WHERE t.sale_id = s.id;
    
    INSERT INTO stock (site_id, article_id, qty)
    SELECT s.id, a.id, floor(random() * 200)::int FROM site s CROSS JOIN article a;
    
    DELETE FROM sale                       -- Station Road's POS "lost" receipts lately
    WHERE site_id = (SELECT id FROM site WHERE site_code = 'S004')
      AND sale_date > current_date - 3 AND random() < 0.6;
    ANALYZE;                               -- refresh planner statistics

    ANALYZE refreshes the statistics the planner uses to choose a plan; Reading query plans and fixing slow queries shows why that matters.

  2. Top 5 products per store, last 7 days

    The first CTE sums units per store and product. The second ranks them inside each store. The final SELECT keeps the top five and adds names.

    SQL
    WITH sold AS (
      SELECT s.site_id, sl.article_id, sum(sl.qty) AS units
      FROM sale s
      JOIN sale_line sl ON sl.sale_id = s.id
      WHERE s.sale_date >= current_date - 7
      GROUP BY s.site_id, sl.article_id
    ), ranked AS (
      SELECT site_id, article_id, units,
             row_number() OVER (PARTITION BY site_id
                                ORDER BY units DESC, article_id) AS rn
      FROM sold
    )
    SELECT si.site_code, a.name AS article, r.units, r.rn
    FROM ranked r
    JOIN site si   ON si.id = r.site_id
    JOIN article a ON a.id = r.article_id
    WHERE r.rn <= 5
    ORDER BY si.site_code, r.rn;
    Output
     site_code |  article  | units | rn
    -----------+-----------+-------+----
     P001      | Article 1 |  1891 |  1
     P001      | Article 2 |   800 |  2
     P001      | Article 3 |   665 |  3
     ...
  3. Daily sales with day-over-day change

    lag() reads the previous day's total. Note the nesting: sum(total_cents) is the group aggregate, and lag(…) OVER runs on top of the grouped rows. The ::numeric cast keeps the fraction in the percentage.

    SQL
    SELECT sale_date,
           sum(total_cents) AS revenue_cents,
           sum(total_cents) - lag(sum(total_cents)) OVER (ORDER BY sale_date) AS change_cents,
           round(100.0 * (sum(total_cents)::numeric
                          / lag(sum(total_cents)) OVER (ORDER BY sale_date) - 1), 1) AS change_pct
    FROM sale
    WHERE site_id = (SELECT id FROM site WHERE site_code = 'S004')
      AND sale_date >= current_date - 6
    GROUP BY sale_date
    ORDER BY sale_date;
    Output
     sale_date  | revenue_cents | change_cents | change_pct
    ------------+---------------+--------------+------------
     2026-10-10 |       1587933 |              |
     2026-10-11 |       1708481 |       120548 |        7.6
     2026-10-12 |       1604104 |      -104377 |       -6.1
     2026-10-13 |       1641566 |        37462 |        2.3
     2026-10-14 |        625247 |     -1016319 |      -61.9   <- the POS problem starts
     2026-10-15 |        668784 |        43537 |        7.0

    Your numbers differ because the data is random. 1587933 cents is $15,879.33. The first row has no previous day, so lag gives NULL. To get month-to-date instead, use sum(sum(total_cents)) OVER (ORDER BY sale_date).

  4. Stores selling below last week

    Two CTEs, one per week, then a comparison. The LEFT JOIN plus coalesce keeps a store that sold nothing this week, which is the most alarming case.

    SQL
    WITH this_week AS (
      SELECT site_id, sum(total_cents) AS revenue_cents
      FROM sale
      WHERE sale_date >= current_date - 6                           -- last 7 days
      GROUP BY site_id
    ), last_week AS (
      SELECT site_id, sum(total_cents) AS revenue_cents
      FROM sale
      WHERE sale_date >= current_date - 13 AND sale_date < current_date - 6
      GROUP BY site_id
    )
    SELECT si.site_code, si.name,
           lw.revenue_cents               AS last_week,
           coalesce(tw.revenue_cents, 0)  AS this_week,
           round(100.0 * (coalesce(tw.revenue_cents, 0)::numeric / lw.revenue_cents - 1), 1)
             AS change_pct
    FROM last_week lw
    JOIN site si           ON si.id = lw.site_id
    LEFT JOIN this_week tw ON tw.site_id = lw.site_id
    WHERE coalesce(tw.revenue_cents, 0) < lw.revenue_cents * 0.9      -- down more than 10%
    ORDER BY change_pct;
    Output
     site_code |     name     | last_week | this_week | change_pct
    -----------+--------------+-----------+-----------+------------
     S004      | Station Road |  11466371 |   8545846 |      -25.5
  5. Prove the dead-stock query works

    A query that returns nothing may be correct, or may be broken. Plant a row you expect it to find, run it, and undo everything with ROLLBACK, so your practice data stays clean. Paste the dead-stock query from the EXISTS section where marked.

    SQL
    BEGIN;
    -- Two new products arrive at Station Road but have not sold yet
    INSERT INTO article (sku, name, category_id, unit_price_cents)
    VALUES ('SKU00041', 'Coconut oil 1L', 1, 450),
           ('SKU00042', 'Basmati rice 1kg', 1, 290);
    INSERT INTO stock (site_id, article_id, qty)
    SELECT s.id, a.id, 24
    FROM site s, article a
    WHERE s.site_code = 'S004' AND a.sku IN ('SKU00041', 'SKU00042');
    
    -- ...paste the dead-stock query here...
    
    ROLLBACK;   -- the test rows disappear
    Output
       sku    |       name       | qty
    ----------+------------------+-----
     SKU00041 | Coconut oil 1L   |  24
     SKU00042 | Basmati rice 1kg |  24

    Both planted products appear, and no real product does, because every real one sold in the last week. Now you trust the query. The same trick works for every report: before you rely on "no stores are below target", plant one that is.

  6. Upsert stock after a delivery

    When goods arrive (a goods received note, or GRN), add the quantity if the row exists, or create it if this site never stocked the product. RETURNING sends back the new row, so the app needs no second query.

    SQL
    INSERT INTO stock (site_id, article_id, qty)
    VALUES (1, 7, 24)
    ON CONFLICT (site_id, article_id)                 -- the primary key is the referee
    DO UPDATE SET qty        = stock.qty + EXCLUDED.qty,   -- EXCLUDED = the row we tried
                  updated_at = now()
    RETURNING site_id, article_id, qty;
    
    -- A retried receipt upload: the second attempt inserts nothing
    INSERT INTO sale (site_id, local_txn_id, sale_date, total_cents, pay_method)
    VALUES (1, 'S001-261016-1', current_date, 4500, 'WALLET')
    ON CONFLICT (site_id, local_txn_id) DO NOTHING
    RETURNING id;                                     -- zero rows means "already had it"

    In Prisma, prisma.stock.upsert() covers the simple cases. For a single unique key with simple data, recent Prisma versions send it as one ON CONFLICT statement; in other cases it reads first and then writes. When the exact SQL matters, write it yourself with $executeRaw.

  7. Call the report from NestJS with $queryRaw

    Prisma's $queryRaw is a . Each ${value} is sent to Postgres as a separate parameter, never pasted into the SQL text, so it is safe from . The generic gives the result a type. Postgres bigint (which is what sum() of an integer column and row_number() return) arrives as JavaScript bigint.

    TypeScript
    import { Injectable } from '@nestjs/common';
    import { PrismaService } from '../prisma/prisma.service';
    
    type TopArticle = {
      site_code: string;
      sku: string;
      name: string;
      units: bigint;          // sum(integer) is bigint in Postgres
      revenue_cents: bigint;  // also a sum of integers: exact, and a bigint
      rn: bigint;             // row_number() is bigint
    };
    
    @Injectable()
    export class ReportsService {
      constructor(private readonly prisma: PrismaService) {}
    
      topArticles(siteCodes: string[], days: number, top = 5): Promise<TopArticle[]> {
        return this.prisma.$queryRaw<TopArticle[]>`
          WITH sold AS (
            SELECT s.site_id, sl.article_id,
                   sum(sl.qty) AS units, sum(sl.qty * sl.unit_price_cents) AS revenue_cents
            FROM sale s
            JOIN sale_line sl ON sl.sale_id = s.id
            JOIN site si      ON si.id = s.site_id
            WHERE si.site_code = ANY(${siteCodes})          -- array becomes one parameter
              AND s.sale_date >= current_date - ${days}::int
            GROUP BY s.site_id, sl.article_id
          ), ranked AS (
            SELECT *, row_number() OVER (PARTITION BY site_id ORDER BY units DESC) AS rn
            FROM sold
          )
          SELECT si.site_code, a.sku, a.name, r.units, r.revenue_cents, r.rn
          FROM ranked r
          JOIN site si   ON si.id = r.site_id
          JOIN article a ON a.id = r.article_id
          WHERE r.rn <= ${top}
          ORDER BY si.site_code, r.rn`;
      }
    }

    The type is a promise Prisma does not check at runtime. If you rename a column in the SQL, update the type too. Convert bigint values to numbers (safe below 253) or strings in the controller's response DTO before sending JSON.

  8. See why $queryRawUnsafe is dangerous

    $queryRawUnsafe takes a plain string. If you build that string with a template literal, user input becomes part of the SQL.

    TypeScript
    const input = "S001' OR '1'='1";   // typed into a search box
    
    await prisma.$queryRaw`SELECT count(*) FROM site WHERE site_code = ${input}`;
    // -> 0 rows matched: the whole string was treated as one site code
    
    await prisma.$queryRawUnsafe(`SELECT count(*) FROM site WHERE site_code = '${input}'`);
    // -> all 8 rows matched: the input rewrote the WHERE clause
    
    await prisma.$queryRawUnsafe('SELECT count(*) FROM site WHERE site_code = $1', input);
    // -> 0 rows: still safe, because the value is passed as a separate $1 parameter

    If you need a dynamic column or table name, which parameters cannot provide, pick it from a fixed allow-list in code. Never pass it through from the request.

Bring it to your team

Ask these when a report is slow, wrong, or done in application code.

  • "Is this aggregation done in SQL, or are we loading rows into Node and summing them there?"
  • "Should stores with zero sales appear in this report? Then is it a LEFT JOIN, with the filter in ON?"
  • "What does this report show when a value is NULL or missing? Did we test that case?"
  • "This report says nothing is wrong. Did we plant a test row to prove it can find a problem?"
  • "Does every list endpoint have an explicit ORDER BY with a tie-breaker?"
  • "Is this insert-or-update an ON CONFLICT, or a read followed by a write that can race?"
  • "Is any raw SQL built by string concatenation, or with $queryRawUnsafe and a template literal?"

Red flags

  • findMany() with no where, followed by a reduce in JavaScript to compute a total
  • $queryRawUnsafe with ${…} inside the string
  • "Stores with no sales" missing from a report that is supposed to list every store
  • Hourly charts grouped on sold_at without converting to the store's time zone
  • NOT IN (subquery) where the subquery can return NULL

Practice

Receipts per store, including the silent ones

The tables hold four stores and a DC, and a few days of sales. For 15 October 2026, list every store (format = 'STORE') with its number of receipts that day, and include stores with no receipts as 0. Return the columns site_code and receipts. The starter forgets the silent store.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Receipts per store on 15 Oct 2026, including stores with none
SELECT si.site_code, count(*) AS receipts
FROM site si
JOIN sale s ON s.site_id = si.id
WHERE s.sale_date = '2026-10-15'
GROUP BY si.site_code;
Ctrl + Enter runs
Show a solution
SQL
SELECT si.site_code, count(s.id) AS receipts   -- count(s.id) skips the NULL row
FROM site si
LEFT JOIN sale s
       ON s.site_id = si.id
      AND s.sale_date = '2026-10-15'             -- right-side filter goes in ON
WHERE si.format = 'STORE'                        -- left-side filter can stay in WHERE
GROUP BY si.site_code;

If you kept count(*), S003 shows 1 instead of 0, because the NULL-filled row is still a row.

Pass the exercise to finish this task.

Top two products in each store

The table units_sold(site_code, article, units) holds last week's units per store and product. Return the two best sellers in each store with the columns site_code, article, units and rn (1 or 2), ordered by site_code and then rn. When two products sold the same number of units, the one whose name comes first alphabetically ranks higher.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- Top 2 products per store
SELECT site_code, article, units
FROM units_sold
ORDER BY units DESC
LIMIT 2;
Ctrl + Enter runs
Show a solution
SQL
WITH ranked AS (
  SELECT site_code, article, units,
         row_number() OVER (PARTITION BY site_code
                            ORDER BY units DESC, article) AS rn   -- article breaks ties
  FROM units_sold
)
SELECT site_code, article, units, rn
FROM ranked
WHERE rn <= 2
ORDER BY site_code, rn;

You cannot filter on rn in the same SELECT that computes it, because WHERE runs first. The CTE computes the rank, and the outer query filters it.

Pass the exercise to finish this task.

Apply a delivery with one upsert

A delivery for site 1 brings 24 units of article 1 (already stocked) and 12 units of article 3 (never stocked here). Rewrite the INSERT so existing rows add the new quantity and missing rows are created, in one statement. The starter fails with a duplicate key error. Keep the final SELECT.

SQL exerciseRuns in a real PostgreSQL inside your browser
-- The delivery: 24 of article 1 and 12 of article 3, for site 1
INSERT INTO stock (site_id, article_id, qty)
VALUES (1, 1, 24), (1, 3, 12);

SELECT site_id, article_id, qty FROM stock WHERE site_id = 1 ORDER BY article_id;
Ctrl + Enter runs
Show a solution
SQL
INSERT INTO stock (site_id, article_id, qty)
VALUES (1, 1, 24), (1, 3, 12)
ON CONFLICT (site_id, article_id)                 -- the primary key is the referee
DO UPDATE SET qty = stock.qty + EXCLUDED.qty;     -- EXCLUDED = the row we tried to insert

SELECT site_id, article_id, qty FROM stock WHERE site_id = 1 ORDER BY article_id;
Pass the exercise to finish this task.

Check yourself

You write FROM site LEFT JOIN sale ON sale.site_id = site.id WHERE sale.sale_date = current_date. What happens to a store with no sales today?

You need the 5 best-selling products for each store. Which tool fits?

Which call is open to SQL injection?

Flashcards

Open my review

Key takeaways

  • Ask the database the precise question. Let SQL filter, group and sum, and send back only the answer.
  • INNER JOIN drops unmatched rows; LEFT JOIN keeps them. Put conditions on the right-hand table in ON, not WHERE.
  • NULL means unknown: use IS NULL, coalesce and NOT EXISTS, and remember that count(col) skips NULLs.
  • CTEs name the steps; window functions (row_number, lag, running sum) rank and compare without losing rows.
  • Use ON CONFLICT for insert-or-update, and the tagged $queryRaw for raw SQL from Prisma.

Sources

Episode transcriptOne precise question to the database · about 12 minutes · tap any line to play from there
  1. NadiaMonday morning call. The head of store operations asks, "Which stores are down more than ten percent against last week?" Imran, how would a team answer that without SQL?
  2. ImranSomeone exports two weeks of sales to a spreadsheet, builds a pivot table, and answers on Tuesday. That is what Tomás used to do.
  3. NadiaBy the end of this episode, you'll know the shape of one query that has that answer ready before the call. Every morning, automatically.
  4. ImranOur engineers use Prisma for almost everything. I rarely see raw SQL.
  5. NadiaAn ORM like Prisma covers daily create, read and update. The questions a business asks, like top sellers per store, need real SQL.
  6. NadiaThe lesson's picture: you want to know how the Riverside store did last week. You could ask the store to send every receipt to head office, then read two thousand slips yourself and add them up.
  7. ImranOr I phone the store manager and ask one precise question. Total sales per day last week, and your three best sellers.
  8. NadiaAnd you get back seven numbers and three names. SQL is that precise question. When NestJS code loads fifty thousand rows into memory to sum them, it's reading every slip by hand.
  9. ImranWhich hurts most on a slow store connection or a busy server.
  10. NadiaRight. First, one thing that explains many beginner errors. You write a query in one order, but Postgres works through it in another. It starts from the tables and joins, then WHERE keeps matching rows, then grouping, then it picks the columns, then sorts, then limits.
  11. ImranSo that's why I can't use a column nickname from SELECT inside WHERE. WHERE runs first.
  12. NadiaExactly. And one more: without ORDER BY, row order isn't guaranteed, even if it looks stable in testing. Any list on a screen needs an explicit ORDER BY.
  13. ImranNow joins. I always just write JOIN.
  14. NadiaThat's an inner join, which keeps only matched pairs. A left join keeps every row of the left table, and fills the missing side with empty values, which SQL calls NULL.
  15. NadiaIf you can see the screen, the first diagram has three stores on the left: Riverside, Hill Road and Old Town. On the right, today's three receipts. Two belong to Riverside, one to Hill Road. Old Town has none.
  16. NadiaBelow are the two results. The inner join gives three rows, and Old Town is gone. The left join gives four rows, with Old Town paired to an empty sale.
  17. ImranAnd for a report on receipts per store, Old Town is the one I care about most.
  18. NadiaYes. A silent store may have a broken POS. Now here's the trap. Say you write the left join, then add "where sale date equals today" at the bottom. What happens to Old Town?
  19. ImranIt stays, because it's a left join. That's what left joins are for.
  20. NadiaThat's the common mistake. Old Town's sale date is NULL. "Is NULL equal to today?" isn't true, so WHERE removes the row. Your left join now behaves like an inner join.
  21. ImranSo the date condition goes into the ON part of the join, not into WHERE.
  22. NadiaExactly. Conditions on the right-hand table of a left join belong in ON. And count the sale id, not count star. Count star counts Old Town's empty row as one.
  23. ImranThat brings up NULL in general.
  24. NadiaNULL means unknown. Any comparison with unknown gives unknown, and WHERE keeps only true rows. So NULL equals NULL is not true. You write "is null" instead.
  25. NadiaAnd the one that bites real reports is NOT IN. "Products not in this promotion list" returns nothing at all if the list has one NULL in it. Use NOT EXISTS instead.
  26. ImranWhat about grouping? WHERE versus HAVING always confuses junior developers.
  27. NadiaWHERE filters rows before grouping. HAVING filters groups after. "Days when a store issued fewer than two hundred receipts" is a filter on a count, so it's HAVING.
  28. ImranAnd EXISTS? I've seen it but never used it.
  29. NadiaIt answers "is there any?" Take dead stock. Which products are on the shelf at Station Road, with no sale in the last seven days? That's a question about the absence of sale lines. NOT EXISTS answers it directly.
  30. NadiaThe database stops looking as soon as it finds one matching row. Postgres usually turns it into an efficient anti-join, which you can confirm with EXPLAIN in the query plans lesson.
  31. ImranTwo more tools are in the lesson, I think.
  32. NadiaDistinct on keeps the first row of each group. "The latest receipt from each store" becomes one short query. And FILTER lets you count only matching rows inside a normal group. In one pass you get all receipts, wallet receipts, and the share paid without cash.
  33. ImranInstead of one query per payment method.
  34. NadiaWhich would scan the sales three times and need code to stitch the results. Now the report toolkit. A CTE, a common table expression, gives a name to one step of a query. First compute this week. Then last week. Then compare.
  35. ImranSo a recipe instead of a nest of brackets.
  36. NadiaYes. And in Postgres twelve and later, naming a simple step usually costs nothing. Then window functions. They calculate across a set of rows, but unlike GROUP BY, they keep every row.
  37. NadiaYou'll use three most. Row number, which counts one, two, three within each group. A running sum, like month-to-date sales. And lag, which reads the previous row, like yesterday's sales next to today's.
  38. NadiaThe second diagram shows row number at work. A table of store, product and units, split into two blocks: one for store S zero zero one, one for S zero zero two. Inside each block, the rows are sorted by units, and numbered from one again.
  39. ImranSo rice five kilo is number one in both stores, and the numbering starts again at one for the second store.
  40. NadiaExactly. Keep the rows numbered five or less, and you have the top five per store.
  41. ImranWhy not just sort by units, descending, and limit to five?
  42. NadiaThat gives five rows for the whole result, probably all from the busiest store. You need five per store. LIMIT applies once to the whole query. A window function with a partition ranks inside each group.
  43. NadiaAnd for time, bucket with date trunc, which cuts a timestamp to the hour, day or month. Convert to the store's time zone first. Otherwise nine pm lands in the three pm bucket.
  44. ImranLast concept. Upserts.
  45. Nadia"Add this row, or update it if it already exists." The naive version is two queries: check, then insert or update. Two handheld scanners posting the same delivery at the same moment can both see "not there", and both insert.
  46. NadiaINSERT with ON CONFLICT does the check and the write as one step, with a unique constraint as the referee. Do update adds to the existing row. Do nothing skips silently, which makes a retried receipt upload harmless.
  47. NadiaThe Build-it uses all of this. You load a month of practice data: eight sites, forty products and thirty days of receipts, with prices in cents. One store, Station Road, loses receipts in the last three days, so the reports have something to find.
  48. NadiaThen four reports. Top five products per store, with two CTEs and row number. Daily sales with day-over-day change, using lag. In the sample output, Station Road drops by about sixty-two percent on one day. That's where the POS problem starts.
  49. ImranAnd the percentages? Our money columns are whole cents now.
  50. NadiaGood catch, that's the trap that comes with cents: dividing one integer sum by another drops the fraction, so the percentage comes out as zero or one. The lesson casts one side to numeric first.
  51. ImranAnd the Monday morning report?
  52. NadiaTwo CTEs, one per week, then a comparison. A left join with coalesce, which turns a missing total into zero, keeps a store that sold nothing this week. That's the most alarming case. In the sample, Station Road is down about twenty-five percent.
  53. ImranWhich is probably a sync problem, not lost customers.
  54. NadiaThe lesson makes that point. A store down sixty percent for three days is often a POS that stopped uploading. Pair it with the zero-receipts report, and you have an early warning for broken uploads.
  55. NadiaThe fourth report is the dead-stock query, and it teaches a habit. On the practice data, it returns nothing, because every article sells every day. Is the query correct, or broken?
  56. ImranI can't tell from an empty result.
  57. NadiaSo you plant rows you expect it to find. Two new products with stock at Station Road and no sales, inside a transaction. Run the query, see both appear, then roll back so the data stays clean.
  58. ImranI like that. Before trusting "no stores below target", plant one that is.
  59. NadiaThen an upsert that adds a delivery's quantity to stock, and finally calling a report from NestJS with Prisma's query raw. It's a tagged template, so each value you insert is sent to Postgres as a separate parameter, never pasted into the SQL text.
  60. ImranThat protects against SQL injection.
  61. NadiaYes. The lesson shows the danger with the unsafe version. Someone types a site code followed by "or one equals one" into a search box. With the safe tagged version, zero rows match. With the unsafe one built from a string, all eight sites match.
  62. NadiaIf you need a dynamic column name, which parameters can't provide, pick it from a fixed allow-list in code.
  63. NadiaThree practice tasks run in your browser: the report that keeps silent stores, the top two products per store, and an upsert for a delivery.
  64. ImranFor my team, then. Is this total computed in SQL, or are we loading rows into Node and summing them there?
  65. NadiaGood first question. Also: should stores with zero sales appear in this report, and if so, is the filter in ON? Did we plant a test row to prove the report can find a problem? And is any raw SQL built by joining strings?
  66. NadiaOne to think about. You need the five best-selling products for each store. Group by store, order by units, limit five? Having count at most five? Or row number with a partition by store? Take a second.
  67. ImranRow number, partitioned by store, then keep rank five or less. The limit version gives five rows in total.
  68. NadiaRight. And having filters groups by their size, not by rank.
  69. ImranMy takeaways. One: ask the database the precise question, and let it send back only the answer. Two: left joins keep the stores with nothing, as long as the filter goes in ON, not WHERE.
  70. ImranThree: CTEs name the steps, and window functions rank and compare without losing rows.
  71. NadiaAnd one addition: NULL means unknown. Use "is null", coalesce, and NOT EXISTS, or your reports go quiet in exactly the cases that matter.
  72. NadiaNext time, we look inside an index, and see how Postgres finds one row among millions in a handful of page reads.
  73. ImranSee you then.
Finished reading?Mark it complete to fill in its stop on your course line.

Use it at work

Monthly sales report

Revenue, orders and average order value per month, with the change on the month before.

Open in the SQL Lab
Next stopIndexes from the inside
Was this lesson helpful?
Questions and discussion

Loading the discussion…